Compare commits

...

12 Commits

Author SHA1 Message Date
maaz519 73956fd1e3 feat: route-centric rules, approve popup, emoji picker, 5-column sync table
- Schema: SyncRouteRuleMap junction table, ruleIntent on TenantRule,
  ForwardFormat/Frequency/ApprovalMode columns on SyncRoute, INTEREST
  match type
- Worker: remove global AUTO_APPROVE forwarding; per-route rule evaluation
  in ingest processor — POSITIVE rules auto-forward that route, NEGATIVE
  rules block it, fallback to approvalMode
- API: PATCH /routes/:id, rule assign/unassign endpoints, approve now
  accepts targetGroupIds, new GET /messages/:id/routes for popup data
- Web: 5-column route table with settings + rules dialogs, ForwardTarget
  popup with rule-based pre-selection, ruleIntent toggle + emoji dropdown
  (👍/👎 only) + INTEREST type in rules manager

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:58:02 +05:30
maaz519 e598073dc7 feat: enrich AI stream with replyToMessageId, sourceGroupName, organizationId + tenant/source filters
- Extract quotedPlatformMsgId from Baileys contextInfo.stanzaId in normalizer
- Thread quotedPlatformMsgId through IngestJobData → ingest processor → Message record
- Widen ingest processor group/tenant selects to include name and organizationId
- Add replyToMessageId, sourceGroupName, organizationId to StreamMessagePayload and XADD
- Update StreamRecord, parseFields, and streamMessages in AI stream service with new fields
- Add optional ?tenant= and ?source= SSE filters; cursor advances for filtered entries
  so Last-Event-ID resume remains accurate across reconnects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 01:07:42 +05:30
maaz519 5a0f7aa58f fix: route browser→API calls through BFF to avoid CORS
- Create /api/onboard/request-otp and /api/auth/member/login BFF routes
- Update OnboardingForm and member-login page to call BFF instead of API directly
- Add vercel.json with monorepo build config and rootDirectory hint
- Add .vercelignore to exclude .turbo, node_modules, .next (was uploading 2.3GB)
- Add apps/web/.gitignore and update root .gitignore for tsbuildinfo files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:01:05 +05:30
maaz519 a3798b3059 feat: show tenancy scope (Org → Chapter) in chapter & member portals
Surface where the logged-in user sits in the hierarchy via a ScopeCard
in the sidebar, below the brand mark.

API:
- Enrich chapter login/me responses with tenantName + organization
  (LoginResponse.admin, AdminProfile types updated)
- Add GET /my/scope returning member's chapter + organization

Web:
- ScopeCard component (Organisation row + Chapter row, themed icons)
- Chapter portal: scope from auth context, blue accent
- Member portal: scope fetched server-side in layout, emerald accent
- PortalShell gains a `scope` slot rendered under the brand

Org and admin portals intentionally omit it — org IS the scope, admin is
platform-wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:27:23 +05:30
maaz519 205418fc4e feat: isolate all four portals with route groups + polished design system
Fix blank /org/login (and /admin/login): the guarded portal layout was
wrapping its own login route, rendering null when unauthenticated. Move
each portal's authed pages into a (authed)/(chapter) route group so login
pages live outside the guard.

Structure:
- app/(chapter)/* — chapter admin pages + guarded layout (was root-level)
- app/org/(authed)/* — org pages + guarded layout; /org/login now free
- app/admin/(authed)/* — admin pages + guarded layout; /admin/login free
- Root layout slimmed to providers only (no shared sidebar)
- Convert moved files' relative imports to @/app alias

Design system:
- PortalShell: shared sidebar shell (brand mark, themed active nav with
  accent bar, avatar dropdown user menu, loading skeletons)
- portal-theme.ts: per-portal theme tokens (violet/slate/blue/emerald)
- PortalLoginShell redesigned: two-column with gradient branding panel,
  dotted pattern, value-prop highlights, built-in back link
- New shadcn components: Avatar, DropdownMenu, Skeleton
- Move shared DraftCard to _components/draft-card.tsx (used by 2 portals)
- Portal selector: remove Super Admin card, icon tiles, hover lift
- All 4 login pages use react-hook-form + Zod + themed shell
- Member nav restored to full 11-section list with icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:20:06 +05:30
maaz519 d7b5988858 feat: portal redesign — shadcn/ui, React Query, Zod, isolated portals
Foundation:
- CLAUDE.md with full coding rules (tech stack, patterns, redirect rules)
- Install @tanstack/react-query, zod, react-hook-form, shadcn/ui deps
- CSS variables design system (Tailwind v4 + tw-animate-css)
- Shared components: Button, Input, Label, Card, Badge, Separator, Form
- PortalLoginShell: two-column login layout (branding left, form right)
- PortalNav: shared sidebar nav used by all portals
- ReactQueryProvider in root layout
- api-client.ts: typed fetch wrapper (no raw fetch in components)

Portal isolation:
- Sidebar now returns null for all non-chapter routes; portals own their layout
- Admin layout: auth guard + PortalNav (slate accent)
- Org layout: auth guard + PortalNav (violet accent)
- Member layout: server-side cookie check → MemberNav client component
- Chapter admin sidebar: PortalNav (blue accent)

Login pages (all use react-hook-form + Zod + PortalLoginShell):
- /login → defaults next to /search (was /) — fixes redirect bug
- /admin/login → replaces /admin
- /org/login → replaces /org
- /member-login → two-step phone+OTP with proper form validation

Portal selector (/) redesigned with accent border-left cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:01:27 +05:30
maaz519 f8b7afcc38 feat: bot assign UI in super admin bot pool page
Click "Assign" on any bot row to expand an inline form with a chapter
dropdown. Selecting a chapter and confirming calls POST /api/admin/bots/:id/assign
with the tenantId. The chapter list is loaded alongside bots on mount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:37:08 +05:30
maaz519 8d720278cb feat: portal selector home page + remove self-serve signup
Replace the home page with a portal selector showing three access paths:
- Organisation Portal → /org/login
- Chapter Portal → /login
- Member Portal → /member-login

Remove /signup and its BFF route — tenants are now only created by org
owners through the org portal or assigned by super admins. Self-serve
community creation is no longer supported.

Update sidebar to bypass auth guard for / (exact match) without
accidentally matching all paths via startsWith.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:28:38 +05:30
maaz519 6bff55db64 feat: org admin can create chapter (tenant) admins
Add POST /org/tenants/:id/admins so org admins can provision the first
login for a chapter's admin team. New chapter admins can then sign in at
/login with email + password.

- OrgService.createTenantAdmin — validates chapter belongs to org, hashes
  password, creates Admin record
- OrgController POST tenants/:id/admins endpoint
- BFF route /api/org/tenants/[id]/admins
- Add Admin form on /org/tenants/[id] page (email, password, role select)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:21:50 +05:30
maaz519 b26a635283 fix: exclude /org paths from tenant auth guard in sidebar
/org/* is the org-admin portal — it has its own auth context and should
not redirect to /login. Add ORG_PATHS bypass and render null sidebar
on those routes (same pattern as /my/*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:17:54 +05:30
maaz519 484ae7bf9d fix: add OrgAdminProvider to root layout
/org/login was throwing "useOrgAdmin must be used within <OrgAdminProvider>"
because the provider was never mounted in the app shell.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:15:21 +05:30
maaz519 8a61908df1 fix: accept role field when creating OrgAdmin
ValidationPipe forbids unknown properties; the UI sends role so the DTO
must declare it. Pass it through to the service so ORG_OWNER/ORG_ADMIN
is honoured on creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:08:58 +05:30
111 changed files with 4603 additions and 1264 deletions
+2
View File
@@ -7,3 +7,5 @@ coverage
*.env.local *.env.local
.env.production .env.production
sessions/ sessions/
*.tsbuildinfo
.vercel
+14
View File
@@ -0,0 +1,14 @@
node_modules
.turbo
.next
dist
build
coverage
*.tsbuildinfo
.git
apps/worker/sessions
apps/api/sessions
sessions
.env
.env.*
!.env.example
+171
View File
@@ -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,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;
+68 -19
View File
@@ -337,20 +337,40 @@ enum ApprovalDecision {
} }
model SyncRoute { model SyncRoute {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
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)
createdAt DateTime @default(now()) forwardFormat ForwardFormat @default(THREADED)
withAttachment Boolean @default(true)
frequency ForwardFrequency @default(INSTANT)
approvalMode ApprovalMode @default(MANUAL)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignedRules SyncRouteRuleMap[]
@@unique([sourceGroupId, targetGroupId]) @@unique([sourceGroupId, targetGroupId])
@@index([tenantId]) @@index([tenantId])
} }
model SyncRouteRuleMap {
id String @id @default(cuid())
syncRouteId String
syncRoute SyncRoute @relation(fields: [syncRouteId], references: [id], onDelete: Cascade)
tenantRuleId String
tenantRule TenantRule @relation(fields: [tenantRuleId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([syncRouteId, tenantRuleId])
@@index([syncRouteId])
@@index([tenantRuleId])
}
// ============================================================================ // ============================================================================
// Group claiming + sharing // Group claiming + sharing
// ============================================================================ // ============================================================================
@@ -515,6 +535,7 @@ enum RuleMatchType {
HASHTAG HASHTAG
PREFIX PREFIX
REACTION_EMOJI REACTION_EMOJI
INTEREST
} }
enum RuleAction { enum RuleAction {
@@ -524,17 +545,45 @@ enum RuleAction {
REJECT REJECT
} }
enum RuleIntent {
POSITIVE
NEGATIVE
}
enum ForwardFormat {
THREADED
DIGEST
SUMMARY
}
enum ForwardFrequency {
INSTANT
FIVE_MIN
FIFTEEN_MIN
ONE_HOUR
ONE_DAY
SEVEN_DAYS
}
enum ApprovalMode {
MANUAL
AUTO
}
model TenantRule { model TenantRule {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
matchType RuleMatchType matchType RuleMatchType
matchValue String matchValue String
action RuleAction action RuleAction
priority Int @default(0) ruleIntent RuleIntent @default(POSITIVE)
isActive Boolean @default(true) priority Int @default(0)
createdAt DateTime @default(now()) isActive Boolean @default(true)
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncRouteRuleMaps SyncRouteRuleMap[]
@@unique([tenantId, matchType, matchValue]) @@unique([tenantId, matchType, matchValue])
@@index([tenantId, isActive]) @@index([tenantId, isActive])
@@ -24,13 +24,15 @@ export class AiStreamController {
@Sse('stream') @Sse('stream')
stream( stream(
@Query('after') after?: string, @Query('after') after?: string,
@Query('tenant') tenant?: string,
@Query('source') source?: string,
@Headers('last-event-id') lastEventId?: string, @Headers('last-event-id') lastEventId?: string,
): Observable<MessageEvent> { ): Observable<MessageEvent> {
// Last-Event-ID (set automatically by EventSource on reconnect) wins so the // Last-Event-ID (set automatically by EventSource on reconnect) wins so the
// client resumes exactly where it dropped. Otherwise honour ?after=, default // client resumes exactly where it dropped. Otherwise honour ?after=, default
// to '$' = only new messages from now on. // to '$' = only new messages from now on.
const cursor = lastEventId || after || '$'; const cursor = lastEventId || after || '$';
return this.service.streamMessages(cursor); return this.service.streamMessages(cursor, { tenantId: tenant, sourceGroupId: source });
} }
@Post('drafts') @Post('drafts')
@@ -18,6 +18,9 @@ interface StreamRecord {
senderJid: string; senderJid: string;
senderName: string | null; senderName: string | null;
sourceGroupId: string; sourceGroupId: string;
sourceGroupName: string | null;
organizationId: string | null;
replyToMessageId: string | null;
tags: string[]; tags: string[];
effectiveAction: string | null; effectiveAction: string | null;
traceId: string | null; traceId: string | null;
@@ -46,7 +49,7 @@ export class AiStreamService {
* '0' → replay the entire retained stream (~50k messages) then follow live * '0' → replay the entire retained stream (~50k messages) then follow live
* '<id>' → resume strictly after that entry (used on reconnect) * '<id>' → resume strictly after that entry (used on reconnect)
*/ */
streamMessages(afterId: string): Observable<MessageEvent> { streamMessages(afterId: string, filters?: { tenantId?: string; sourceGroupId?: string }): Observable<MessageEvent> {
return new Observable<MessageEvent>((subscriber) => { return new Observable<MessageEvent>((subscriber) => {
const redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null }); const redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null });
let active = true; let active = true;
@@ -70,8 +73,11 @@ export class AiStreamService {
for (const [, entries] of res) { for (const [, entries] of res) {
for (const [id, fields] of entries) { for (const [id, fields] of entries) {
const record = parseFields(fields);
cursor = id; cursor = id;
subscriber.next({ id, data: parseFields(fields) } as MessageEvent); 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) { } catch (err) {
@@ -142,6 +148,9 @@ function parseFields(fields: string[]): StreamRecord {
senderJid: obj.senderJid ?? '', senderJid: obj.senderJid ?? '',
senderName: obj.senderName || null, senderName: obj.senderName || null,
sourceGroupId: obj.sourceGroupId ?? '', sourceGroupId: obj.sourceGroupId ?? '',
sourceGroupName: obj.sourceGroupName || null,
organizationId: obj.organizationId || null,
replyToMessageId: obj.replyToMessageId || null,
tags, tags,
effectiveAction: obj.effectiveAction || null, effectiveAction: obj.effectiveAction || null,
traceId: obj.traceId || null, traceId: obj.traceId || null,
+13 -4
View File
@@ -26,10 +26,13 @@ export class AuthService {
) {} ) {}
async login(req: LoginRequest): Promise<LoginResponse> { async login(req: LoginRequest): Promise<LoginResponse> {
let tenant: { id: string; slug: string } | null = null; let tenant:
| { id: string; slug: string; name: string; organization: { id: string; slug: string; name: string } | null }
| null = null;
const tenantInclude = { organization: { select: { id: true, slug: true, name: true } } } as const;
if (req.tenantSlug) { if (req.tenantSlug) {
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } }); tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug }, include: tenantInclude });
if (!tenant) { if (!tenant) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
@@ -48,7 +51,7 @@ export class AuthService {
'Email is registered in multiple tenants — please specify tenantSlug', 'Email is registered in multiple tenants — please specify tenantSlug',
); );
} }
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId } }); const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId }, include: tenantInclude });
if (!found) { if (!found) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
@@ -93,6 +96,8 @@ export class AuthService {
role: admin.role as AdminRole, role: admin.role as AdminRole,
tenantId: admin.tenantId, tenantId: admin.tenantId,
tenantSlug: tenant.slug, tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: tenant.organization,
}, },
}; };
} }
@@ -100,7 +105,7 @@ export class AuthService {
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> { async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
const admin = await this.prisma.admin.findFirst({ const admin = await this.prisma.admin.findFirst({
where: { id: adminId, tenantId }, where: { id: adminId, tenantId },
include: { tenant: true }, include: { tenant: { include: { organization: { select: { id: true, slug: true, name: true } } } } },
}); });
if (!admin) throw new UnauthorizedException('Admin not found'); if (!admin) throw new UnauthorizedException('Admin not found');
return { return {
@@ -110,6 +115,8 @@ export class AuthService {
role: admin.role as AdminRole, role: admin.role as AdminRole,
tenantId: admin.tenantId, tenantId: admin.tenantId,
tenantSlug: admin.tenant.slug, tenantSlug: admin.tenant.slug,
tenantName: admin.tenant.name,
organization: admin.tenant.organization,
}, },
}; };
} }
@@ -192,6 +199,8 @@ export class AuthService {
role: PrismaAdminRole.OWNER, role: PrismaAdminRole.OWNER,
tenantId: tenant.id, tenantId: tenant.id,
tenantSlug: tenant.slug, tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: null,
}, },
}; };
} }
@@ -1,4 +1,5 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { IsArray, IsOptional, IsString } from 'class-validator';
import { MessagesService } from './messages.service'; import { MessagesService } from './messages.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@@ -6,6 +7,10 @@ import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator'; import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context'; import { TenantContext } from '../../common/tenant-context';
class ApproveMessageDto {
@IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[];
}
@Controller('admin/messages') @Controller('admin/messages')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN') @Roles('OWNER', 'ADMIN')
@@ -22,6 +27,11 @@ export class MessagesController {
return this.messagesService.pendingCount(ctx.tenantId); return this.messagesService.pendingCount(ctx.tenantId);
} }
@Get(':id/routes')
getRoutes(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.messagesService.getRoutesForMessage(ctx.tenantId, id);
}
@Get(':id') @Get(':id')
get( get(
@CurrentTenantContext() ctx: TenantContext, @CurrentTenantContext() ctx: TenantContext,
@@ -34,8 +44,9 @@ export class MessagesController {
approve( approve(
@CurrentTenantContext() ctx: TenantContext, @CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string, @Param('id') id: string,
@Body() body: ApproveMessageDto,
) { ) {
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id); return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id, body.targetGroupIds);
} }
@Post('reindex') @Post('reindex')
@@ -1,7 +1,7 @@
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common'; import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { ForwardJobData, IndexJobData } from '@tower/types'; import { ForwardJobData, IndexJobData, RouteForMessage } from '@tower/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types'; import { AuditAction } from '../audit/audit.types';
@@ -112,7 +112,76 @@ export class MessagesService {
})); }));
} }
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> { async getRoutesForMessage(tenantId: string, messageId: string): Promise<RouteForMessage[]> {
const message = await this.prisma.message.findUnique({
where: { id: messageId },
select: { id: true, tenantId: true, sourceGroupId: true, content: true },
});
if (!message) throw new NotFoundException('Message not found');
if (message.tenantId !== tenantId) throw new NotFoundException('Message not found');
const syncRoutes = await this.prisma.syncRoute.findMany({
where: { sourceGroupId: message.sourceGroupId, isActive: true },
include: {
targetGroup: { select: { id: true, name: true, isActive: true } },
assignedRules: {
include: {
tenantRule: {
select: { matchType: true, matchValue: true, ruleIntent: true, isActive: true },
},
},
},
},
});
return syncRoutes
.filter((r: any) => r.targetGroup?.isActive)
.map((route: any) => {
const activeRules = route.assignedRules
.filter((m: any) => m.tenantRule.isActive)
.map((m: any) => m.tenantRule);
let ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE' = 'NONE';
if (activeRules.length > 0) {
const negativeRules = activeRules.filter((r: any) => r.ruleIntent === 'NEGATIVE');
const positiveRules = activeRules.filter((r: any) => r.ruleIntent === 'POSITIVE');
if (negativeRules.length > 0 && this.matchesAny(message.content, negativeRules)) {
ruleDecision = 'BLOCK';
} else if (positiveRules.length > 0 && this.matchesAny(message.content, positiveRules)) {
ruleDecision = 'FORWARD';
}
}
return {
routeId: route.id,
targetGroupId: route.targetGroup.id,
targetGroupName: route.targetGroup.name,
approvalMode: route.approvalMode,
forwardFormat: route.forwardFormat,
withAttachment: route.withAttachment,
frequency: route.frequency,
ruleDecision,
} as RouteForMessage;
});
}
private matchesAny(content: string, rules: Array<{ matchType: string; matchValue: string }>): boolean {
for (const rule of rules) {
if (rule.matchType === 'HASHTAG') {
const escaped = rule.matchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i').test(content)) return true;
} else if (rule.matchType === 'PREFIX') {
if (content.trimStart().startsWith(rule.matchValue)) return true;
} else if (rule.matchType === 'REACTION_EMOJI') {
if (content.includes(rule.matchValue)) return true;
}
}
return false;
}
async approve(tenantId: string, adminId: string, messageId: string, targetGroupIds?: string[]): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
const message = await this.prisma.message.findUnique({ const message = await this.prisma.message.findUnique({
where: { id: messageId }, where: { id: messageId },
include: { include: {
@@ -160,10 +229,14 @@ export class MessagesService {
throw new ConflictException('Message could not be approved (concurrent update)'); throw new ConflictException('Message could not be approved (concurrent update)');
} }
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter( const allRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
(r: any) => r.targetGroup != null, (r: any) => r.targetGroup != null,
); );
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({ const selectedRoutes = targetGroupIds
? allRoutes.filter((r: any) => targetGroupIds.includes(r.targetGroupId))
: allRoutes;
const forwardJobs: ForwardJobData[] = selectedRoutes.map((route: any) => ({
tenantId: message.tenantId, tenantId: message.tenantId,
messageId: message.id, messageId: message.id,
content: message.content, content: message.content,
@@ -204,6 +277,7 @@ export class MessagesService {
resourceId: message.id, resourceId: message.id,
payload: { payload: {
routesForwarded: forwardJobs.length, routesForwarded: forwardJobs.length,
targetGroupIds: targetGroupIds ?? null,
contentPreview: message.content.slice(0, 80), contentPreview: message.content.slice(0, 80),
}, },
}); });
+5
View File
@@ -105,6 +105,11 @@ export class MyController {
return this.service.getDashboard(member.sub, member.tenantId); return this.service.getDashboard(member.sub, member.tenantId);
} }
@Get('scope')
scope(@CurrentMember() member: MemberJwtPayload) {
return this.service.getScope(member.sub, member.tenantId);
}
@Get('digest') @Get('digest')
digests(@CurrentMember() member: MemberJwtPayload) { digests(@CurrentMember() member: MemberJwtPayload) {
return this.service.getDigests(member.tenantId); return this.service.getDigests(member.tenantId);
+23
View File
@@ -186,6 +186,29 @@ export class MyService {
}; };
} }
async getScope(userId: string, tenantId: string) {
const user = await this.prisma.towerUser.findFirst({
where: { id: userId, tenantId },
select: {
displayName: true,
tenant: {
select: {
id: true,
slug: true,
name: true,
organization: { select: { id: true, slug: true, name: true } },
},
},
},
});
if (!user) throw new NotFoundException('User not found');
return {
member: { displayName: user.displayName },
chapter: { id: user.tenant.id, slug: user.tenant.slug, name: user.tenant.name },
organization: user.tenant.organization,
};
}
async getDashboard(userId: string, tenantId: string) { async getDashboard(userId: string, tenantId: string) {
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } }); const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new NotFoundException('User not found'); if (!user) throw new NotFoundException('User not found');
@@ -32,6 +32,15 @@ export class OrgController {
return this.orgService.getTenant(admin.organizationId, id); return this.orgService.getTenant(admin.organizationId, id);
} }
@Post('tenants/:id/admins')
createTenantAdmin(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Param('id') id: string,
@Body() body: { email: string; password: string; role?: string },
) {
return this.orgService.createTenantAdmin(admin.organizationId, id, body.email, body.password, body.role);
}
@Get('rules') @Get('rules')
getRules(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) { getRules(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
return this.orgService.getRules(admin.organizationId); return this.orgService.getRules(admin.organizationId);
+22
View File
@@ -162,4 +162,26 @@ export class OrgService {
if (!rule) throw new NotFoundException('Rule not found'); if (!rule) throw new NotFoundException('Rule not found');
await this.prisma.orgRule.delete({ where: { id: ruleId } }); await this.prisma.orgRule.delete({ where: { id: ruleId } });
} }
async createTenantAdmin(
organizationId: string,
tenantId: string,
email: string,
password: string,
role?: string,
) {
const tenant = await this.prisma.tenant.findFirst({ where: { id: tenantId, organizationId } });
if (!tenant) throw new NotFoundException('Chapter not found or does not belong to this org');
const existing = await this.prisma.admin.findUnique({
where: { tenantId_email: { tenantId, email } },
});
if (existing) throw new ConflictException('An admin with this email already exists for this chapter');
const passwordHash = await bcrypt.hash(password, 10);
return this.prisma.admin.create({
data: { tenantId, email, passwordHash, ...(role ? { role: role as any } : {}) },
select: { id: true, email: true, role: true, createdAt: true },
});
}
} }
@@ -1,8 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { RoutesService } from './routes.service'; import { RoutesService } from './routes.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator'; import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context'; import { TenantContext } from '../../common/tenant-context';
import { IsString } from 'class-validator';
class CreateRouteDto { class CreateRouteDto {
@IsString() sourceGroupId!: string; @IsString() sourceGroupId!: string;
@@ -14,6 +14,14 @@ class BatchCreateRouteDto {
@IsString({ each: true }) targetGroupIds!: string[]; @IsString({ each: true }) targetGroupIds!: string[];
} }
class UpdateRouteDto {
@IsEnum(['THREADED', 'DIGEST', 'SUMMARY']) @IsOptional() forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
@IsBoolean() @IsOptional() withAttachment?: boolean;
@IsEnum(['INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS']) @IsOptional() frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
@IsEnum(['MANUAL', 'AUTO']) @IsOptional() approvalMode?: 'MANUAL' | 'AUTO';
@IsBoolean() @IsOptional() isActive?: boolean;
}
@Controller('routes') @Controller('routes')
export class RoutesController { export class RoutesController {
constructor(private readonly routesService: RoutesService) {} constructor(private readonly routesService: RoutesService) {}
@@ -36,6 +44,35 @@ export class RoutesController {
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds); return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
} }
@Patch(':id')
update(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string, @Body() body: UpdateRouteDto) {
return this.routesService.update(ctx.tenantId, id, body);
}
@Get(':id/rules')
listRules(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.routesService.listAssignedRules(ctx.tenantId, id);
}
@Post(':id/rules/:tenantRuleId')
assignRule(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('tenantRuleId') tenantRuleId: string,
) {
return this.routesService.assignRule(ctx.tenantId, id, tenantRuleId);
}
@Delete(':id/rules/:tenantRuleId')
@HttpCode(204)
async unassignRule(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('tenantRuleId') tenantRuleId: string,
) {
await this.routesService.unassignRule(ctx.tenantId, id, tenantRuleId);
}
@Delete(':id') @Delete(':id')
@HttpCode(204) @HttpCode(204)
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) { async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
@@ -9,6 +9,14 @@ export interface BatchCreateResult {
skipped: string[]; skipped: string[];
} }
export interface UpdateRouteInput {
forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment?: boolean;
frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode?: 'MANUAL' | 'AUTO';
isActive?: boolean;
}
const routeInclude = { const routeInclude = {
sourceGroup: { select: { name: true, tenantId: true } }, sourceGroup: { select: { name: true, tenantId: true } },
targetGroup: { select: { name: true, tenantId: true } }, targetGroup: { select: { name: true, tenantId: true } },
@@ -30,11 +38,70 @@ export class RoutesService {
include: { include: {
sourceGroup: { select: { name: true } }, sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } }, targetGroup: { select: { name: true } },
assignedRules: {
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true },
},
},
},
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
async update(tenantId: string, id: string, dto: UpdateRouteInput) {
const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException(`Route ${id} not found`);
return this.prisma.syncRoute.update({ where: { id }, data: dto as any });
}
async assignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const rule = await this.prisma.tenantRule.findFirst({ where: { id: tenantRuleId, tenantId } });
if (!rule) throw new NotFoundException(`Rule ${tenantRuleId} not found`);
try {
return await this.prisma.syncRouteRuleMap.create({
data: { id: `${routeId}_${tenantRuleId}`.slice(0, 25) + Math.random().toString(36).slice(2, 8), syncRouteId: routeId, tenantRuleId },
include: { tenantRule: { select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true } } },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Rule is already assigned to this route');
}
throw e;
}
}
async unassignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const map = await this.prisma.syncRouteRuleMap.findUnique({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
if (!map) throw new NotFoundException('Assignment not found');
await this.prisma.syncRouteRuleMap.delete({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
}
async listAssignedRules(tenantId: string, routeId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const maps = await this.prisma.syncRouteRuleMap.findMany({
where: { syncRouteId: routeId },
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, priority: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
});
return maps.map((m: any) => ({ ...m.tenantRule, assignedAt: m.createdAt }));
}
async create(tenantId: string, sourceGroupId: string, targetGroupId: string) { async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
if (!sourceGroupId || !targetGroupId) { if (!sourceGroupId || !targetGroupId) {
throw new BadRequestException('sourceGroupId and targetGroupId are required'); throw new BadRequestException('sourceGroupId and targetGroupId are required');
+19 -32
View File
@@ -6,27 +6,32 @@ import { PrismaService } from '../../prisma/prisma.service';
export class RulesService { export class RulesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string): Promise<TenantRuleData[]> { private toData(r: any): TenantRuleData {
const rows = await this.prisma.tenantRule.findMany({ return {
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => ({
id: r.id, id: r.id,
tenantId: r.tenantId, tenantId: r.tenantId,
matchType: r.matchType, matchType: r.matchType,
matchValue: r.matchValue, matchValue: r.matchValue,
action: r.action, action: r.action,
ruleIntent: r.ruleIntent,
priority: r.priority, priority: r.priority,
isActive: r.isActive, isActive: r.isActive,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(), updatedAt: r.updatedAt.toISOString(),
})); };
}
async list(tenantId: string): Promise<TenantRuleData[]> {
const rows = await this.prisma.tenantRule.findMany({
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => this.toData(r));
} }
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> { async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
const existing = await this.prisma.tenantRule.findUnique({ const existing = await this.prisma.tenantRule.findUnique({
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType, matchValue: req.matchValue } }, where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType as any, matchValue: req.matchValue } },
}); });
if (existing) { if (existing) {
throw new ConflictException('A rule with this matchType + matchValue already exists'); throw new ConflictException('A rule with this matchType + matchValue already exists');
@@ -35,25 +40,16 @@ export class RulesService {
const row = await this.prisma.tenantRule.create({ const row = await this.prisma.tenantRule.create({
data: { data: {
tenantId, tenantId,
matchType: req.matchType, matchType: req.matchType as any,
matchValue: req.matchValue, matchValue: req.matchValue,
action: req.action, action: req.action as any,
ruleIntent: (req.ruleIntent ?? 'POSITIVE') as any,
priority: req.priority ?? 0, priority: req.priority ?? 0,
isActive: req.isActive ?? true, isActive: req.isActive ?? true,
}, },
}); });
return { return this.toData(row);
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
} }
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> { async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
@@ -64,22 +60,13 @@ export class RulesService {
if (req.matchType !== undefined) data.matchType = req.matchType; if (req.matchType !== undefined) data.matchType = req.matchType;
if (req.matchValue !== undefined) data.matchValue = req.matchValue; if (req.matchValue !== undefined) data.matchValue = req.matchValue;
if (req.action !== undefined) data.action = req.action; if (req.action !== undefined) data.action = req.action;
if (req.ruleIntent !== undefined) data.ruleIntent = req.ruleIntent;
if (req.priority !== undefined) data.priority = req.priority; if (req.priority !== undefined) data.priority = req.priority;
if (req.isActive !== undefined) data.isActive = req.isActive; if (req.isActive !== undefined) data.isActive = req.isActive;
const row = await this.prisma.tenantRule.update({ where: { id }, data }); const row = await this.prisma.tenantRule.update({ where: { id }, data });
return { return this.toData(row);
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
} }
async remove(tenantId: string, id: string): Promise<void> { async remove(tenantId: string, id: string): Promise<void> {
@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { IsOptional, IsString, MinLength } from 'class-validator'; import { IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { OrgAdminRole } from '@prisma/client';
import { SuperAdminGuard } from './super-admin.guard'; import { SuperAdminGuard } from './super-admin.guard';
import { SuperAdminService } from './super-admin.service'; import { SuperAdminService } from './super-admin.service';
@@ -12,6 +13,7 @@ class CreateOrgAdminDto {
@IsString() email!: string; @IsString() email!: string;
@IsString() @IsOptional() name?: string; @IsString() @IsOptional() name?: string;
@IsString() @MinLength(8) password!: string; @IsString() @MinLength(8) password!: string;
@IsEnum(OrgAdminRole) @IsOptional() role?: OrgAdminRole;
} }
class AssignTenantOrgDto { class AssignTenantOrgDto {
@@ -40,7 +42,7 @@ export class AdminOrgsController {
@Post('orgs/:id/admins') @Post('orgs/:id/admins')
createOrgAdmin(@Param('id') id: string, @Body() body: CreateOrgAdminDto) { createOrgAdmin(@Param('id') id: string, @Body() body: CreateOrgAdminDto) {
return this.service.createOrgAdmin(id, body.email, body.name, body.password); return this.service.createOrgAdmin(id, body.email, body.name, body.password, body.role);
} }
@Patch('tenants/:id/org') @Patch('tenants/:id/org')
@@ -77,7 +77,7 @@ export class SuperAdminService {
return org; return org;
} }
async createOrgAdmin(orgId: string, email: string, name: string | undefined, password: string) { async createOrgAdmin(orgId: string, email: string, name: string | undefined, password: string, role?: string) {
const org = await this.prisma.organization.findUnique({ where: { id: orgId } }); const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
if (!org) throw new NotFoundException('Organization not found'); if (!org) throw new NotFoundException('Organization not found');
@@ -88,7 +88,7 @@ export class SuperAdminService {
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
const admin = await this.prisma.orgAdmin.create({ const admin = await this.prisma.orgAdmin.create({
data: { organizationId: orgId, email, name, passwordHash }, data: { organizationId: orgId, email, name, passwordHash, ...(role ? { role: role as any } : {}) },
select: { id: true, email: true, name: true, role: true, createdAt: true }, select: { id: true, email: true, name: true, role: true, createdAt: true },
}); });
return admin; return admin;
+1
View File
@@ -0,0 +1 @@
.vercel
@@ -1,7 +1,7 @@
import { apiFetch } from '../_lib/api'; import { apiFetch } from '@/app/_lib/api';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getToken } from '../_lib/api'; import { getToken } from '@/app/_lib/api';
import { DraftCard } from '../admin/drafts/DraftCard'; import { DraftCard } from '@/app/_components/draft-card';
import Link from 'next/link'; import Link from 'next/link';
type DraftType = 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM'; type DraftType = 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
@@ -0,0 +1,250 @@
'use client';
import { useState } from 'react';
import { RouteSettingsDialog } from './RouteSettingsDialog';
import { RouteRulesDialog } from './RouteRulesDialog';
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface AssignedRule {
id: string;
matchType: string;
matchValue: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
}
interface Route {
id: string;
sourceGroupId: string;
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
assignedRules?: Array<{ tenantRule: AssignedRule }>;
}
interface Group {
id: string;
name: string;
platform: string;
isActive: boolean;
}
const FREQ_LABELS: Record<ForwardFrequency, string> = {
INSTANT: 'Instant', FIVE_MIN: '5 min', FIFTEEN_MIN: '15 min',
ONE_HOUR: '1h', ONE_DAY: '1d', SEVEN_DAYS: '7d',
};
const FORMAT_LABELS: Record<ForwardFormat, string> = {
THREADED: 'Threaded', DIGEST: 'Digest', SUMMARY: 'Summary',
};
export function RouteManager({
groups,
initialRoutes,
}: {
groups: Group[];
initialRoutes: Route[];
}) {
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
const [sourceId, setSourceId] = useState('');
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [settingsRoute, setSettingsRoute] = useState<Route | null>(null);
const [rulesRoute, setRulesRoute] = useState<Route | null>(null);
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
for (const r of routes) {
const key = r.sourceGroup.name;
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
grouped.get(key)!.targets.push(r);
}
function toggleTarget(id: string) {
setTargetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
setError(null);
}
async function addRoutes() {
if (!sourceId || targetIds.size === 0) return;
setBusy(true); setError(null);
try {
const res = await fetch('/api/routes/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
});
if (res.status === 409) { setError((await res.json()).message ?? 'Routes already exist'); return; }
if (!res.ok) { setError('Failed to create routes'); return; }
const created: Route[] = await res.json();
setRoutes((prev) => [...created, ...prev]);
setSourceId(''); setTargetIds(new Set());
} finally { setBusy(false); }
}
async function deleteRoute(id: string) {
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
if (res.ok || res.status === 204) setRoutes((prev) => prev.filter((r) => r.id !== id));
}
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
return (
<div className="flex flex-col gap-6">
<section>
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
{routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
<th className="px-4 py-2.5">Source</th>
<th className="px-4 py-2.5">Target</th>
<th className="px-4 py-2.5">Rules</th>
<th className="px-4 py-2.5">Frequency</th>
<th className="px-4 py-2.5">Format</th>
<th className="px-4 py-2.5">Approval</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{routes.map((r) => {
const ruleCount = r.assignedRules?.length ?? 0;
return (
<tr key={r.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="px-4 py-2.5 font-medium text-gray-700">{r.sourceGroup.name}</td>
<td className="px-4 py-2.5 text-gray-600">{r.targetGroup.name}</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${ruleCount > 0 ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-400'}`}>
{ruleCount} rule{ruleCount !== 1 ? 's' : ''}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600">{FREQ_LABELS[r.frequency] ?? r.frequency}</td>
<td className="px-4 py-2.5 text-gray-600">
{FORMAT_LABELS[r.forwardFormat] ?? r.forwardFormat}
{r.withAttachment && <span className="ml-1 text-[10px] text-gray-400">+attach</span>}
</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${r.approvalMode === 'AUTO' ? 'bg-green-100 text-green-700' : 'bg-yellow-50 text-yellow-700'}`}>
{r.approvalMode === 'AUTO' ? 'Auto' : 'Manual'}
</span>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<button
onClick={() => setSettingsRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Edit settings"
>
</button>
<button
onClick={() => setRulesRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Manage rules"
>
📋
</button>
<button
onClick={() => void deleteRoute(r.id)}
className="text-xs text-gray-400 hover:text-red-600"
title="Delete route"
>
🗑
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<section>
<h2 className="text-base font-semibold mb-3">Add routes</h2>
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center">
<select
value={sourceId}
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
>
<option value="">Source group</option>
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<span className="text-gray-400 text-sm"></span>
<span className="text-xs text-gray-500">{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected</span>
</div>
{sourceId && eligibleTargets.length > 0 && (
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
{eligibleTargets.map((g) => (
<label
key={g.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
targetIds.has(g.id) ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={targetIds.has(g.id)}
onChange={() => toggleTarget(g.id)}
className="rounded border-gray-300"
/>
{g.name}
</label>
))}
</div>
)}
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
)}
<button
onClick={() => void addRoutes()}
disabled={!sourceId || targetIds.size === 0 || busy}
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
</button>
</div>
</section>
{settingsRoute && (
<RouteSettingsDialog
route={settingsRoute}
onSaved={(updates) => {
setRoutes((prev) => prev.map((r) => r.id === settingsRoute.id ? { ...r, ...updates } : r));
}}
onClose={() => setSettingsRoute(null)}
/>
)}
{rulesRoute && (
<RouteRulesDialog
routeId={rulesRoute.id}
targetGroupName={rulesRoute.targetGroup.name}
onClose={() => setRulesRoute(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,167 @@
'use client';
import { useEffect, useState } from 'react';
interface AssignedRule {
id: string;
matchType: string;
matchValue: string;
action: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
isActive: boolean;
assignedAt: string;
}
interface TenantRule {
id: string;
matchType: string;
matchValue: string;
action: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
isActive: boolean;
}
export function RouteRulesDialog({
routeId,
targetGroupName,
onClose,
}: {
routeId: string;
targetGroupName: string;
onClose: () => void;
}) {
const [assigned, setAssigned] = useState<AssignedRule[]>([]);
const [allRules, setAllRules] = useState<TenantRule[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
Promise.all([
fetch(`/api/routes/${routeId}/rules`).then((r) => r.json()),
fetch('/api/rules').then((r) => r.json()),
]).then(([a, all]) => {
setAssigned(Array.isArray(a) ? a : []);
setAllRules(Array.isArray(all) ? all : []);
}).catch(() => {}).finally(() => setLoading(false));
}, [routeId]);
const assignedIds = new Set(assigned.map((r) => r.id));
const available = allRules.filter((r) => r.isActive && !assignedIds.has(r.id));
async function assign(tenantRuleId: string) {
setBusy(tenantRuleId);
try {
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'POST' });
if (res.ok) {
const rule = allRules.find((r) => r.id === tenantRuleId)!;
setAssigned((prev) => [...prev, { ...rule, assignedAt: new Date().toISOString() }]);
}
} finally {
setBusy(null);
}
}
async function unassign(tenantRuleId: string) {
setBusy(tenantRuleId);
try {
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'DELETE' });
if (res.ok || res.status === 204) {
setAssigned((prev) => prev.filter((r) => r.id !== tenantRuleId));
}
} finally {
setBusy(null);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-5 flex flex-col gap-4 max-h-[80vh]">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold">Rules for route</h2>
<p className="text-xs text-gray-500"> {targetGroupName}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
{loading ? (
<p className="text-sm text-gray-400">Loading</p>
) : (
<div className="flex flex-col gap-4 overflow-y-auto">
<section>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Assigned ({assigned.length})
</h3>
{assigned.length === 0 ? (
<p className="text-sm text-gray-400">No rules assigned route uses approval mode only.</p>
) : (
<ul className="flex flex-col gap-1.5">
{assigned.map((rule) => (
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
<span className="text-sm font-mono">{rule.matchValue}</span>
<span className="text-xs text-gray-400">{rule.matchType}</span>
</div>
<button
onClick={() => void unassign(rule.id)}
disabled={busy === rule.id}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
Remove
</button>
</li>
))}
</ul>
)}
</section>
{available.length > 0 && (
<section>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Add from your rules
</h3>
<ul className="flex flex-col gap-1.5">
{available.map((rule) => (
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-dashed border-gray-200 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
<span className="text-sm font-mono">{rule.matchValue}</span>
<span className="text-xs text-gray-400">{rule.matchType}</span>
</div>
<button
onClick={() => void assign(rule.id)}
disabled={busy === rule.id}
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
+ Add
</button>
</li>
))}
</ul>
</section>
)}
{available.length === 0 && assigned.length > 0 && (
<p className="text-xs text-gray-400">All active rules are assigned to this route.</p>
)}
</div>
)}
<div className="flex justify-end pt-1">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Done
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface Route {
id: string;
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
}
const FORMAT_OPTIONS: { value: ForwardFormat; label: string }[] = [
{ value: 'THREADED', label: 'Threaded' },
{ value: 'DIGEST', label: 'Digest' },
{ value: 'SUMMARY', label: 'Summary' },
];
const FREQ_OPTIONS: { value: ForwardFrequency; label: string }[] = [
{ value: 'INSTANT', label: 'Instant' },
{ value: 'FIVE_MIN', label: '5 min' },
{ value: 'FIFTEEN_MIN', label: '15 min' },
{ value: 'ONE_HOUR', label: '1 hour' },
{ value: 'ONE_DAY', label: '1 day' },
{ value: 'SEVEN_DAYS', label: '7 days' },
];
export function RouteSettingsDialog({
route,
onSaved,
onClose,
}: {
route: Route;
onSaved: (updated: Partial<Route>) => void;
onClose: () => void;
}) {
const [forwardFormat, setForwardFormat] = useState<ForwardFormat>(route.forwardFormat);
const [withAttachment, setWithAttachment] = useState(route.withAttachment);
const [frequency, setFrequency] = useState<ForwardFrequency>(route.frequency);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(route.approvalMode);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function save() {
setBusy(true);
setError('');
try {
const res = await fetch(`/api/routes/${route.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ forwardFormat, withAttachment, frequency, approvalMode }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
setError(err.message ?? 'Failed to save');
return;
}
onSaved({ forwardFormat, withAttachment, frequency, approvalMode });
onClose();
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Route settings</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Forward format</label>
<select
value={forwardFormat}
onChange={(e) => setForwardFormat(e.target.value as ForwardFormat)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
{FORMAT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={withAttachment}
onChange={(e) => setWithAttachment(e.target.checked)}
className="rounded border-gray-300"
/>
Include attachments
</label>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as ForwardFrequency)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
{FREQ_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Approval mode</label>
<div className="flex gap-2">
{(['MANUAL', 'AUTO'] as ApprovalMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setApprovalMode(m)}
className={`flex-1 rounded px-3 py-2 text-sm font-medium border transition-colors ${
approvalMode === m
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{m === 'MANUAL' ? 'Manual' : 'Auto'}
</button>
))}
</div>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-1">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void save()}
disabled={busy}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { RouteManager } from './RouteManager'; import { RouteManager } from './RouteManager';
import { GroupsTabs } from './GroupsTabs'; import { GroupsTabs } from './GroupsTabs';
import { apiFetch } from '../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface Group { interface Group {
id: string; id: string;
@@ -22,6 +22,12 @@ interface Route {
targetGroupId: string; targetGroupId: string;
sourceGroup: { name: string }; sourceGroup: { name: string };
targetGroup: { name: string }; targetGroup: { name: string };
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode: 'MANUAL' | 'AUTO';
isActive: boolean;
assignedRules?: Array<{ tenantRule: { id: string; matchType: string; matchValue: string; ruleIntent: 'POSITIVE' | 'NEGATIVE' } }>;
} }
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string }; type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/app/_lib/auth-context';
import { PortalShell } from '@/app/_components/portal-shell';
import { ScopeCard } from '@/app/_components/scope-card';
import { Search, Users, Inbox, FileText, SlidersHorizontal, Bot } from 'lucide-react';
const NAV = [
{ href: '/search', label: 'Search', icon: <Search /> },
{ href: '/groups', label: 'Groups & Routes', icon: <Users /> },
{ href: '/messages/pending', label: 'Pending', icon: <Inbox /> },
{ href: '/drafts', label: 'AI Drafts', icon: <FileText /> },
{ href: '/settings/rules', label: 'Rules', icon: <SlidersHorizontal /> },
{ href: '/settings/bot', label: 'Bot', icon: <Bot /> },
];
export default function ChapterLayout({ children }: { children: React.ReactNode }) {
const { admin, loading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading && !admin) router.replace(`/login?next=${encodeURIComponent(pathname)}`);
}, [admin, loading, pathname, router]);
return (
<PortalShell
portalName="Chapter"
theme="blue"
navItems={NAV}
loading={loading}
authed={!!admin}
user={admin ? { email: admin.email, name: admin.name, role: admin.role, subtitle: admin.tenantName } : undefined}
scope={admin ? <ScopeCard accent="text-blue-600" organization={admin.organization} chapter={admin.tenantName ? { name: admin.tenantName } : null} /> : undefined}
onLogout={() => { void logout(); router.replace('/login'); }}
>
{children}
</PortalShell>
);
}
@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
interface RouteForMessage {
routeId: string;
targetGroupId: string;
targetGroupName: string;
approvalMode: 'MANUAL' | 'AUTO';
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: string;
ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE';
}
export function ForwardTargetDialog({
messageId,
onApproved,
onClose,
}: {
messageId: string;
onApproved: () => void;
onClose: () => void;
}) {
const [routes, setRoutes] = useState<RouteForMessage[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
useEffect(() => {
fetch(`/api/messages/${messageId}/routes`)
.then((r) => r.json())
.then((data: RouteForMessage[]) => {
setRoutes(Array.isArray(data) ? data : []);
// Pre-select: FORWARD decision, or AUTO approvalMode with no BLOCK
const preSelected = new Set(
(Array.isArray(data) ? data : [])
.filter((r) => r.ruleDecision === 'FORWARD' || (r.ruleDecision === 'NONE' && r.approvalMode === 'AUTO'))
.map((r) => r.targetGroupId),
);
setSelected(preSelected);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [messageId]);
function toggleGroup(groupId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId); else next.add(groupId);
return next;
});
}
async function confirm() {
setBusy(true);
try {
const res = await fetch(`/api/messages/${messageId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetGroupIds: [...selected] }),
});
if (res.ok) {
onApproved();
onClose();
}
} finally {
setBusy(false);
}
}
const filtered = routes.filter((r) =>
r.targetGroupName.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 flex flex-col max-h-[80vh]">
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-gray-100">
<h2 className="text-base font-semibold">Approve &amp; Forward</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="px-5 py-3 border-b border-gray-100">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups…"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{loading ? (
<p className="text-sm text-gray-400">Loading routes</p>
) : filtered.length === 0 ? (
<p className="text-sm text-gray-400">No target groups found.</p>
) : (
<ul className="flex flex-col gap-1.5">
{filtered.map((r) => {
const isBlocked = r.ruleDecision === 'BLOCK';
const isChecked = selected.has(r.targetGroupId);
return (
<li key={r.targetGroupId}>
<label
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors ${
isBlocked
? 'opacity-50 cursor-default'
: isChecked
? 'bg-blue-50'
: 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => !isBlocked && toggleGroup(r.targetGroupId)}
disabled={isBlocked}
className="rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 truncate">{r.targetGroupName}</div>
{isBlocked && (
<div className="text-[10px] text-red-500">Blocked by assigned rule</div>
)}
{r.ruleDecision === 'FORWARD' && (
<div className="text-[10px] text-green-600">Auto-forward by rule</div>
)}
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-gray-100">
<span className="text-xs text-gray-500">{selected.size} group{selected.size !== 1 ? 's' : ''} selected</span>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void confirm()}
disabled={busy || selected.size === 0}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Approving…' : 'Approve & Forward'}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '../../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface MessageDetail { interface MessageDetail {
id: string; id: string;
@@ -0,0 +1,115 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { ForwardTargetDialog } from '../ForwardTargetDialog';
interface PendingMessage {
id: string;
content: string;
senderJid: string;
senderName: string | null;
tags: string[];
createdAt: string;
sourceGroupId: string;
sourceGroupName: string;
sourceGroupPlatformId: string;
}
export default function PendingMessagesPage() {
const [messages, setMessages] = useState<PendingMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
fetch('/api/messages/pending')
.then(async (res) => {
if (!res.ok) throw new Error(`API ${res.status}`);
return res.json() as Promise<PendingMessage[]>;
})
.then((data) => { setMessages(data); setError(null); })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
return (
<div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-2">Pending messages</h1>
<p className="text-sm text-gray-500 mb-6">
Messages waiting for admin approval. Click "Approve &amp; Forward" to choose which groups to send to.
</p>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
Failed to load: {error}
</div>
)}
{!loading && !error && messages.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
No pending messages right now.
</div>
)}
<ul className="space-y-3">
{messages.map((m) => (
<PendingMessageRow
key={m.id}
message={m}
onApproved={() => setMessages((prev) => prev.filter((x) => x.id !== m.id))}
/>
))}
</ul>
</div>
);
}
function PendingMessageRow({
message,
onApproved,
}: {
message: PendingMessage;
onApproved: () => void;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
<div className="text-xs text-gray-500">{new Date(message.createdAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-500">
From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
{message.tags.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{message.tags.map((t) => (
<span key={t} className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700">
{t}
</span>
))}
</span>
)}
</div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">{message.content}</div>
<div className="flex justify-end pt-1">
<button
onClick={() => setDialogOpen(true)}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Approve &amp; Forward
</button>
</div>
{dialogOpen && (
<ForwardTargetDialog
messageId={message.id}
onApproved={onApproved}
onClose={() => setDialogOpen(false)}
/>
)}
</li>
);
}
@@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface MeiliHit { interface MeiliHit {
id: string; id: string;
@@ -1,5 +1,5 @@
import { BotSettingsCard } from './BotSettingsCard'; import { BotSettingsCard } from './BotSettingsCard';
import { apiFetch } from '../../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface BotSummary { interface BotSummary {
id: string; id: string;
@@ -2,48 +2,71 @@
import { useState } from 'react'; import { useState } from 'react';
type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
type RuleIntent = 'POSITIVE' | 'NEGATIVE';
interface RuleData { interface RuleData {
id: string; id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; matchType: RuleMatchType;
matchValue: string; matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; action: RuleAction;
ruleIntent: RuleIntent;
priority: number; priority: number;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
const MATCH_TYPE_LABELS: Record<string, string> = { const MATCH_TYPE_LABELS: Record<RuleMatchType, string> = {
HASHTAG: 'Hashtag', HASHTAG: 'Hashtag',
PREFIX: 'Prefix', PREFIX: 'Prefix',
REACTION_EMOJI: 'Reaction Emoji', REACTION_EMOJI: 'Reaction Emoji',
INTEREST: 'Interest',
}; };
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<RuleAction, string> = {
FLAG: 'Flag (Pending)', FLAG: 'Flag (Pending)',
AUTO_APPROVE: 'Auto-approve', AUTO_APPROVE: 'Auto-approve',
SKIP: 'Skip (Silent Drop)', SKIP: 'Skip (Silent Drop)',
REJECT: 'Reject (Visible)', REJECT: 'Reject (Visible)',
}; };
const REACTION_OPTIONS = [
{ value: '👍', label: '👍 Thumbs Up' },
{ value: '👎', label: '👎 Thumbs Down' },
];
function getValuePlaceholder(matchType: RuleMatchType): string {
if (matchType === 'HASHTAG') return '#important';
if (matchType === 'PREFIX') return '!!';
if (matchType === 'INTEREST') return '#education';
return '';
}
export function RuleManager({ initial }: { initial: RuleData[] }) { export function RuleManager({ initial }: { initial: RuleData[] }) {
const [rules, setRules] = useState<RuleData[]>(initial); const [rules, setRules] = useState<RuleData[]>(initial);
const [matchType, setMatchType] = useState<'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'>('HASHTAG'); const [matchType, setMatchType] = useState<RuleMatchType>('HASHTAG');
const [matchValue, setMatchValue] = useState(''); const [matchValue, setMatchValue] = useState('');
const [action, setAction] = useState<'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'>('FLAG'); const [action, setAction] = useState<RuleAction>('FLAG');
const [ruleIntent, setRuleIntent] = useState<RuleIntent>('POSITIVE');
const [priority, setPriority] = useState(0); const [priority, setPriority] = useState(0);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
function handleMatchTypeChange(type: RuleMatchType) {
setMatchType(type);
setMatchValue('');
}
async function addRule() { async function addRule() {
if (!matchValue.trim()) return; if (!matchValue.trim()) return;
setBusy(true); setBusy(true); setError('');
setError('');
try { try {
const res = await fetch('/api/rules', { const res = await fetch('/api/rules', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, priority }), body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, ruleIntent, priority }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Failed to create rule' })); const err = await res.json().catch(() => ({ message: 'Failed to create rule' }));
@@ -52,12 +75,8 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
} }
const created: RuleData = await res.json(); const created: RuleData = await res.json();
setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority)); setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority));
setMatchValue(''); setMatchValue(''); setAction('FLAG'); setRuleIntent('POSITIVE'); setPriority(0);
setAction('FLAG'); } finally { setBusy(false); }
setPriority(0);
} finally {
setBusy(false);
}
} }
async function toggleRule(rule: RuleData) { async function toggleRule(rule: RuleData) {
@@ -86,28 +105,69 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<label className="text-xs text-gray-500">Type</label> <label className="text-xs text-gray-500">Type</label>
<select <select
value={matchType} value={matchType}
onChange={(e) => setMatchType(e.target.value as any)} onChange={(e) => handleMatchTypeChange(e.target.value as RuleMatchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm" className="border border-gray-300 rounded px-3 py-2 text-sm"
> >
<option value="HASHTAG">Hashtag</option> <option value="HASHTAG">Hashtag</option>
<option value="PREFIX">Prefix</option> <option value="PREFIX">Prefix</option>
<option value="REACTION_EMOJI">Reaction Emoji</option> <option value="REACTION_EMOJI">Reaction Emoji</option>
<option value="INTEREST">Interest</option>
</select> </select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Value</label> <label className="text-xs text-gray-500">Value</label>
<input {matchType === 'REACTION_EMOJI' ? (
value={matchValue} <select
onChange={(e) => setMatchValue(e.target.value)} value={matchValue}
placeholder={matchType === 'REACTION_EMOJI' ? '⭐' : '#important'} onChange={(e) => setMatchValue(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40" className="border border-gray-300 rounded px-3 py-2 text-sm w-44"
/> >
<option value="">Select emoji</option>
{REACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
) : (
<input
value={matchValue}
onChange={(e) => setMatchValue(e.target.value)}
placeholder={getValuePlaceholder(matchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40"
/>
)}
{matchType === 'INTEREST' && (
<span className="text-[10px] text-gray-400 mt-0.5">Helps AI classify content into circles</span>
)}
</div> </div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Intent</label>
<div className="flex gap-1">
{(['POSITIVE', 'NEGATIVE'] as RuleIntent[]).map((i) => (
<button
key={i}
type="button"
onClick={() => setRuleIntent(i)}
className={`px-3 py-2 text-sm rounded border transition-colors ${
ruleIntent === i
? i === 'POSITIVE'
? 'bg-green-600 text-white border-green-600'
: 'bg-red-600 text-white border-red-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{i === 'POSITIVE' ? '+ Pos' : ' Neg'}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Action</label> <label className="text-xs text-gray-500">Action</label>
<select <select
value={action} value={action}
onChange={(e) => setAction(e.target.value as any)} onChange={(e) => setAction(e.target.value as RuleAction)}
className="border border-gray-300 rounded px-3 py-2 text-sm" className="border border-gray-300 rounded px-3 py-2 text-sm"
> >
<option value="FLAG">Flag (Pending)</option> <option value="FLAG">Flag (Pending)</option>
@@ -116,6 +176,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<option value="REJECT">Reject (Visible)</option> <option value="REJECT">Reject (Visible)</option>
</select> </select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Priority</label> <label className="text-xs text-gray-500">Priority</label>
<input <input
@@ -125,6 +186,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
className="border border-gray-300 rounded px-3 py-2 text-sm w-20" className="border border-gray-300 rounded px-3 py-2 text-sm w-20"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={() => void addRule()} onClick={() => void addRule()}
@@ -140,13 +202,14 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<section> <section>
<h2 className="text-base font-semibold mb-3">Active Rules</h2> <h2 className="text-base font-semibold mb-3">Active Rules</h2>
{rules.length === 0 ? ( {rules.length === 0 ? (
<p className="text-sm text-gray-400">No rules configured. Messages without matching rules are ignored.</p> <p className="text-sm text-gray-400">No rules configured. Assign rules to sync routes to control auto-forwarding.</p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 text-left text-gray-500"> <tr className="border-b border-gray-200 text-left text-gray-500">
<th className="pb-2 pr-3">Type</th> <th className="pb-2 pr-3">Type</th>
<th className="pb-2 pr-3">Value</th> <th className="pb-2 pr-3">Value</th>
<th className="pb-2 pr-3">Intent</th>
<th className="pb-2 pr-3">Action</th> <th className="pb-2 pr-3">Action</th>
<th className="pb-2 pr-3">Priority</th> <th className="pb-2 pr-3">Priority</th>
<th className="pb-2 pr-3">Active</th> <th className="pb-2 pr-3">Active</th>
@@ -158,6 +221,13 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<tr key={rule.id} className="border-b border-gray-100"> <tr key={rule.id} className="border-b border-gray-100">
<td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td> <td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td>
<td className="py-2 pr-3 font-mono">{rule.matchValue}</td> <td className="py-2 pr-3 font-mono">{rule.matchValue}</td>
<td className="py-2 pr-3">
<span className={`text-xs rounded px-2 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
</td>
<td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td> <td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td>
<td className="py-2 pr-3">{rule.priority}</td> <td className="py-2 pr-3">{rule.priority}</td>
<td className="py-2 pr-3"> <td className="py-2 pr-3">
@@ -1,11 +1,12 @@
import { RuleManager } from './RuleManager'; import { RuleManager } from './RuleManager';
import { apiFetch } from '../../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface RuleData { interface RuleData {
id: string; id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
matchValue: string; matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
ruleIntent: 'POSITIVE' | 'NEGATIVE';
priority: number; priority: number;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
@@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '../../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface ThreadMessage { interface ThreadMessage {
id: string; id: string;
@@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface ThreadSummary { interface ThreadSummary {
id: string; id: string;
@@ -0,0 +1,85 @@
import Link from 'next/link';
import { cn } from '../_lib/utils';
import { PORTAL_THEMES, type PortalTheme } from './portal-theme';
import { Check, ArrowLeft } from 'lucide-react';
interface PortalLoginShellProps {
title: string;
subtitle: string;
theme: PortalTheme;
/** Short value-prop bullets shown on the branding panel */
highlights?: string[];
children: React.ReactNode;
footer?: React.ReactNode;
}
export function PortalLoginShell({ title, subtitle, theme, highlights = [], children, footer }: PortalLoginShellProps) {
const t = PORTAL_THEMES[theme];
return (
<div className="min-h-screen grid lg:grid-cols-2">
{/* Branding panel */}
<div className={cn('relative hidden lg:flex flex-col justify-between p-12 text-white overflow-hidden', t.gradient)}>
{/* decorative pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)',
backgroundSize: '24px 24px',
}}
/>
<div className="relative flex items-center gap-3">
<div className="size-9 rounded-xl bg-white/15 backdrop-blur flex items-center justify-center font-bold">T</div>
<span className="font-semibold text-lg tracking-tight">TOWER</span>
</div>
<div className="relative space-y-6">
<h2 className="text-3xl font-bold leading-tight max-w-sm">{title}</h2>
{highlights.length > 0 && (
<ul className="space-y-3">
{highlights.map((h) => (
<li key={h} className="flex items-center gap-3 text-sm text-white/90">
<span className="size-5 rounded-full bg-white/15 flex items-center justify-center shrink-0">
<Check className="size-3" />
</span>
{h}
</li>
))}
</ul>
)}
</div>
<p className="relative text-sm text-white/60">Community Knowledge Infrastructure Platform</p>
</div>
{/* Form panel */}
<div className="flex items-center justify-center p-6 sm:p-8 bg-background">
<div className="w-full max-w-sm space-y-8">
{/* Mobile logo */}
<div className="flex items-center gap-2 lg:hidden">
<div className={cn('size-8 rounded-lg flex items-center justify-center text-white font-bold text-sm', t.gradient)}>T</div>
<span className="font-semibold">TOWER</span>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{children}
<div className="space-y-3 text-center">
{footer}
<Link
href="/"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-3" />
Back to portal selector
</Link>
</div>
</div>
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/app/_lib/utils';
import { Skeleton } from './ui/skeleton';
import { Avatar, AvatarFallback } from './ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { PORTAL_THEMES, type PortalTheme, initials } from './portal-theme';
import { ChevronsUpDown, LogOut } from 'lucide-react';
export interface PortalNavItem {
href: string;
label: string;
icon: React.ReactNode;
}
interface PortalUser {
name?: string | null;
email?: string | null;
role?: string | null;
subtitle?: string | null;
}
interface PortalShellProps {
portalName: string;
theme: PortalTheme;
navItems: PortalNavItem[];
user?: PortalUser;
/** Optional scope indicator (e.g. <ScopeCard/>) rendered below the brand */
scope?: React.ReactNode;
loading: boolean;
authed: boolean;
onLogout: () => void;
children: React.ReactNode;
}
export function PortalShell({
portalName, theme, navItems, user, scope, loading, authed, onLogout, children,
}: PortalShellProps) {
const pathname = usePathname();
const t = PORTAL_THEMES[theme];
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside className="w-60 shrink-0 flex flex-col border-r bg-sidebar">
{/* Brand */}
<div className="h-16 flex items-center gap-3 px-5 border-b">
<div className={cn('size-8 rounded-lg flex items-center justify-center text-white font-bold text-sm shadow-sm', t.gradient)}>
T
</div>
<div className="flex flex-col leading-none">
<span className="font-semibold text-sm tracking-tight">TOWER</span>
<span className="text-[11px] text-muted-foreground mt-0.5">{portalName}</span>
</div>
</div>
{/* Scope */}
{!loading && authed && scope}
{/* Nav */}
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{loading ? (
<div className="space-y-2 p-1">
{Array.from({ length: navItems.length }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
</div>
) : (
navItems.map((item) => {
// Portal root (e.g. /admin, /org) is a single segment — match exactly so it
// doesn't stay highlighted on sub-routes. Deeper items match by prefix.
const isRoot = item.href.split('/').filter(Boolean).length === 1;
const isActive = isRoot
? pathname === item.href
: pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? cn(t.activeBg, t.activeText, 'font-medium')
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{isActive && <span className={cn('absolute left-0 top-1/2 -translate-y-1/2 h-5 w-1 rounded-r-full', t.bar)} />}
<span className="size-4 shrink-0 [&_svg]:size-4">{item.icon}</span>
{item.label}
</Link>
);
})
)}
</nav>
{/* User */}
<div className="p-3 border-t">
{loading ? (
<div className="flex items-center gap-3 px-1">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2.5 w-16" />
</div>
</div>
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger className="w-full flex items-center gap-3 rounded-lg p-2 hover:bg-accent transition-colors text-left outline-none">
<Avatar>
<AvatarFallback className={cn(t.gradient, 'text-white')}>{initials(user.name, user.email)}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.name ?? user.email}</p>
<p className="text-xs text-muted-foreground truncate">{user.subtitle ?? user.role ?? user.email}</p>
</div>
<ChevronsUpDown className="size-4 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="w-52">
<DropdownMenuLabel className="flex flex-col">
<span className="text-sm truncate">{user.name ?? user.email}</span>
<span className="text-xs font-normal text-muted-foreground truncate">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</aside>
{/* Main */}
<main className="flex-1 overflow-auto bg-muted/30">
{!loading && authed ? (
<div className="p-6 lg:p-8 max-w-6xl mx-auto w-full">{children}</div>
) : (
<div className="p-8 space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
</div>
)}
</main>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
export type PortalTheme = 'violet' | 'slate' | 'blue' | 'emerald';
interface ThemeTokens {
/** Gradient for the logo mark and login branding panel */
gradient: string;
/** Text accent for the brand wordmark */
text: string;
/** Active nav item background */
activeBg: string;
/** Active nav item text */
activeText: string;
/** Active left indicator bar */
bar: string;
/** Ring/border tint used in login branding */
soft: string;
}
export const PORTAL_THEMES: Record<PortalTheme, ThemeTokens> = {
violet: {
gradient: 'bg-gradient-to-br from-violet-500 to-purple-700',
text: 'text-violet-600',
activeBg: 'bg-violet-50',
activeText: 'text-violet-700',
bar: 'bg-violet-600',
soft: 'text-violet-100',
},
slate: {
gradient: 'bg-gradient-to-br from-slate-700 to-slate-900',
text: 'text-slate-800',
activeBg: 'bg-slate-100',
activeText: 'text-slate-900',
bar: 'bg-slate-800',
soft: 'text-slate-200',
},
blue: {
gradient: 'bg-gradient-to-br from-blue-500 to-indigo-700',
text: 'text-blue-600',
activeBg: 'bg-blue-50',
activeText: 'text-blue-700',
bar: 'bg-blue-600',
soft: 'text-blue-100',
},
emerald: {
gradient: 'bg-gradient-to-br from-emerald-500 to-teal-700',
text: 'text-emerald-600',
activeBg: 'bg-emerald-50',
activeText: 'text-emerald-700',
bar: 'bg-emerald-600',
soft: 'text-emerald-100',
},
};
export function initials(name?: string | null, email?: string | null): string {
const source = (name ?? email ?? '?').trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return source.slice(0, 2).toUpperCase();
}
+40
View File
@@ -0,0 +1,40 @@
import { Building2, Network } from 'lucide-react';
import { cn } from '../_lib/utils';
interface ScopeCardProps {
organization?: { name: string } | null;
chapter?: { name: string } | null;
/** Tailwind text color for the icons, e.g. text-blue-600 */
accent?: string;
}
/**
* Shows the tenancy scope (Organisation → Chapter) the current user
* is operating within. Rendered in a portal sidebar below the brand.
*/
export function ScopeCard({ organization, chapter, accent = 'text-muted-foreground' }: ScopeCardProps) {
if (!organization && !chapter) return null;
return (
<div className="mx-3 mt-3 rounded-lg border bg-card divide-y">
{organization && (
<div className="flex items-center gap-2.5 px-3 py-2">
<Network className={cn('size-4 shrink-0', accent)} />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground leading-none">Organisation</p>
<p className="text-sm font-medium truncate mt-0.5">{organization.name}</p>
</div>
</div>
)}
{chapter && (
<div className="flex items-center gap-2.5 px-3 py-2">
<Building2 className={cn('size-4 shrink-0', accent)} />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground leading-none">Chapter</p>
<p className="text-sm font-medium truncate mt-0.5">{chapter.name}</p>
</div>
</div>
)}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/app/_lib/utils';
const Avatar = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex size-9 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium', className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../_lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-emerald-100 text-emerald-700',
warning: 'border-transparent bg-amber-100 text-amber-700',
},
},
defaultVariants: { variant: 'default' },
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../_lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { cn } from '../../_lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
@@ -0,0 +1,76 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@/app/_lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
};
+100
View File
@@ -0,0 +1,100 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import { Controller, FormProvider, useFormContext, type ControllerProps, type FieldPath, type FieldValues } from 'react-hook-form';
import { cn } from '../../_lib/utils';
import { Label } from './label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { name: TName };
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) throw new Error('useFormField should be used within <FormField>');
const { id } = itemContext;
return { id, name: fieldContext.name, formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState };
};
type FormItemContextValue = { id: string };
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<React.ComponentRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn('text-sm text-muted-foreground', className)} {...props} />;
},
);
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : children;
if (!body) return null;
return (
<p ref={ref} id={formMessageId} className={cn('text-sm font-medium text-destructive', className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = 'FormMessage';
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '../../_lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = 'Input';
export { Input };
+19
View File
@@ -0,0 +1,19 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '../../_lib/utils';
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+21
View File
@@ -0,0 +1,21 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '../../_lib/utils';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px', className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/app/_lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
}
export { Skeleton };
+29
View File
@@ -0,0 +1,29 @@
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.json().catch(() => ({ message: res.statusText })) as { message?: string };
throw new Error(body.message ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(url: string) =>
fetch(url, { cache: 'no-store' }).then((r) => handleResponse<T>(r)),
post: <T>(url: string, body?: unknown) =>
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
}).then((r) => handleResponse<T>(r)),
patch: <T>(url: string, body: unknown) =>
fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((r) => handleResponse<T>(r)),
del: (url: string) =>
fetch(url, { method: 'DELETE' }).then((r) => handleResponse<void>(r)),
};
+1
View File
@@ -10,6 +10,7 @@ export interface AuthAdmin {
tenantId: string; tenantId: string;
tenantSlug: string; tenantSlug: string;
tenantName?: string; tenantName?: string;
organization?: { id: string; slug: string; name: string } | null;
} }
interface AuthState { interface AuthState {
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1 },
mutations: { retry: 0 },
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
-130
View File
@@ -1,130 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from './super-admin-context';
import { useAuth } from './auth-context';
const NAV_LINKS = [
{ href: '/search', label: 'Search' },
{ href: '/groups', label: 'Groups & Routes' },
{ href: '/messages/pending', label: 'Pending messages' },
{ href: '/drafts', label: 'AI Drafts' },
{ href: '/settings/rules', label: 'Rules' },
{ href: '/settings/bot', label: 'Bot' },
];
const SUPER_ADMIN_LINKS = [
{ href: '/admin', label: 'Dashboard' },
{ href: '/admin/tenants', label: 'Tenants' },
{ href: '/admin/bots', label: 'Bot Pool' },
{ href: '/admin/drafts', label: 'AI Drafts' },
];
const PUBLIC_PATHS = ['/login', '/signup', '/onboard', '/member-login'];
const ADMIN_PATHS = ['/admin'];
const MEMBER_PATHS = ['/my'];
export function Sidebar() {
const { admin, loading, logout } = useAuth();
const { admin: superAdmin, logout: superLogout } = useSuperAdmin();
const pathname = usePathname();
const router = useRouter();
const [pendingCount, setPendingCount] = useState<number | null>(null);
useEffect(() => {
fetch('/api/messages/pending/count')
.then((r) => r.ok ? r.json() : null)
.then((data) => setPendingCount(data?.count ?? null))
.catch(() => setPendingCount(null));
}, []);
useEffect(() => {
if (loading) return;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return;
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) return;
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) return;
if (!admin) {
router.replace(`/login?next=${encodeURIComponent(pathname)}`);
}
}, [loading, admin, pathname, router]);
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4">
<span className="font-bold text-base">TOWER</span>
</nav>
);
}
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER Admin</span>
<div className="flex flex-col gap-1 flex-1">
{SUPER_ADMIN_LINKS.map((link) => (
<Link key={link.href} href={link.href} className="rounded px-3 py-2 text-sm hover:bg-gray-100">
{link.label}
</Link>
))}
</div>
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
<div className="px-3 text-xs text-gray-500">
<div className="font-medium text-gray-700 truncate">{superAdmin?.email}</div>
<div className="uppercase tracking-wide">Super Admin</div>
</div>
<button
type="button"
onClick={() => void superLogout()}
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
>
Sign out
</button>
</div>
</nav>
);
}
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
return null;
}
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER</span>
<div className="flex flex-col gap-1 flex-1">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded px-3 py-2 text-sm hover:bg-gray-100 flex items-center justify-between"
>
<span>{link.label}</span>
{link.href === '/messages/pending' && pendingCount !== null && pendingCount > 0 && (
<span className="bg-blue-600 text-white text-[11px] font-bold rounded-full w-5 h-5 flex items-center justify-center">
{pendingCount > 99 ? '99+' : pendingCount}
</span>
)}
</Link>
))}
</div>
{admin && (
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
<div className="px-3 text-xs text-gray-500">
<div className="font-medium text-gray-700 truncate">{admin.name ?? admin.email}</div>
<div className="truncate">{admin.tenantName}</div>
<div className="uppercase tracking-wide">{admin.role}</div>
</div>
<button
type="button"
onClick={() => void logout()}
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
>
Sign out
</button>
</div>
)}
</nav>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -1,22 +1,30 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context'; import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
export default function BotsPage() { export default function BotsPage() {
const { admin, loading } = useSuperAdmin(); const { admin, loading } = useSuperAdmin();
const router = useRouter(); const router = useRouter();
const [bots, setBots] = useState<any[]>([]); const [bots, setBots] = useState<any[]>([]);
const [tenants, setTenants] = useState<any[]>([]);
const [initiating, setInitiating] = useState(false); const [initiating, setInitiating] = useState(false);
const [pairingInfo, setPairingInfo] = useState<{ pairingToken: string; expiresAt: string } | null>(null); const [pairingInfo, setPairingInfo] = useState<{ pairingToken: string; expiresAt: string } | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null); const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrStatus, setQrStatus] = useState<string>('PAIRING'); const [qrStatus, setQrStatus] = useState<string>('PAIRING');
const [assigningBotId, setAssigningBotId] = useState<string | null>(null);
const [assignTenantId, setAssignTenantId] = useState('');
const [assignBusy, setAssignBusy] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
async function load() { async function load() {
const res = await fetch('/api/admin/bots'); const [botsRes, tenantsRes] = await Promise.all([
if (res.ok) setBots(await res.json()); fetch('/api/admin/bots'),
fetch('/api/admin/tenants'),
]);
if (botsRes.ok) setBots(await botsRes.json());
if (tenantsRes.ok) setTenants(await tenantsRes.json());
} }
useEffect(() => { useEffect(() => {
@@ -78,6 +86,28 @@ export default function BotsPage() {
} }
} }
async function assignBot() {
if (!assigningBotId || !assignTenantId) return;
setAssignBusy(true);
try {
const res = await fetch(`/api/admin/bots/${assigningBotId}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantId: assignTenantId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.message ?? 'Failed to assign bot');
return;
}
setAssigningBotId(null);
setAssignTenantId('');
void load();
} finally {
setAssignBusy(false);
}
}
if (loading) return <p className="text-gray-500">Loading...</p>; if (loading) return <p className="text-gray-500">Loading...</p>;
if (!admin) return null; if (!admin) return null;
@@ -120,34 +150,74 @@ export default function BotsPage() {
<th className="px-4 py-3 font-medium">JID</th> <th className="px-4 py-3 font-medium">JID</th>
<th className="px-4 py-3 font-medium">Name</th> <th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Status</th> <th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Tenants</th> <th className="px-4 py-3 font-medium">Chapters</th>
<th className="px-4 py-3 font-medium">Created</th> <th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
{bots.map((b: any) => ( {bots.map((b: any) => (
<tr key={b.id} className="hover:bg-gray-50"> <>
<td className="px-4 py-3 font-mono text-xs">{b.jid?.slice(0, 30) ?? 'pending...'}</td> <tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-3">{b.displayName ?? '—'}</td> <td className="px-4 py-3 font-mono text-xs">{b.jid?.slice(0, 30) ?? 'pending...'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">{b.displayName ?? '—'}</td>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${ <td className="px-4 py-3">
b.status === 'ACTIVE' ? 'bg-green-100 text-green-700' : <span className={`text-xs font-medium px-2 py-1 rounded-full ${
b.status === 'PAIRING' ? 'bg-yellow-100 text-yellow-700' : b.status === 'ACTIVE' ? 'bg-green-100 text-green-700' :
'bg-gray-100 text-gray-500' b.status === 'PAIRING' ? 'bg-yellow-100 text-yellow-700' :
}`}>{b.status}</span> 'bg-gray-100 text-gray-500'
</td> }`}>{b.status}</span>
<td className="px-4 py-3">{b.tenantCount}</td> </td>
<td className="px-4 py-3 text-xs text-gray-500">{new Date(b.createdAt).toLocaleDateString()}</td> <td className="px-4 py-3">{b.tenantCount}</td>
<td className="px-4 py-3"> <td className="px-4 py-3 text-xs text-gray-500">{new Date(b.createdAt).toLocaleDateString()}</td>
<button <td className="px-4 py-3 flex gap-3">
onClick={() => removeBot(b.id)} <button
className="text-red-600 text-xs hover:underline" onClick={() => { setAssigningBotId(assigningBotId === b.id ? null : b.id); setAssignTenantId(''); }}
> className="text-blue-600 text-xs hover:underline"
Remove >
</button> Assign
</td> </button>
</tr> <button
onClick={() => removeBot(b.id)}
className="text-red-600 text-xs hover:underline"
>
Remove
</button>
</td>
</tr>
{assigningBotId === b.id && (
<tr key={`${b.id}-assign`} className="bg-blue-50">
<td colSpan={6} className="px-4 py-3">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-gray-600">Assign to chapter:</span>
<select
value={assignTenantId}
onChange={(e) => setAssignTenantId(e.target.value)}
className="border rounded-lg px-3 py-1.5 text-sm flex-1 max-w-xs bg-white"
>
<option value=""> select chapter </option>
{tenants.map((t: any) => (
<option key={t.id} value={t.id}>{t.name} ({t.slug})</option>
))}
</select>
<button
onClick={() => void assignBot()}
disabled={!assignTenantId || assignBusy}
className="bg-blue-600 text-white rounded-lg px-3 py-1.5 text-xs font-medium hover:bg-blue-700 disabled:opacity-50"
>
{assignBusy ? 'Assigning...' : 'Confirm'}
</button>
<button
onClick={() => { setAssigningBotId(null); setAssignTenantId(''); }}
className="text-xs text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
</td>
</tr>
)}
</>
))} ))}
</tbody> </tbody>
</table> </table>
@@ -1,7 +1,7 @@
import { apiFetch } from '../../_lib/api'; import { apiFetch } from '@/app/_lib/api';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getToken } from '../../_lib/api'; import { getToken } from '@/app/_lib/api';
import { DraftCard } from './DraftCard'; import { DraftCard } from '@/app/_components/draft-card';
import Link from 'next/link'; import Link from 'next/link';
type DraftType = 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM'; type DraftType = 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { PortalShell } from '@/app/_components/portal-shell';
import { LayoutDashboard, Building2, Network, Server, FileText } from 'lucide-react';
const NAV = [
{ href: '/admin', label: 'Dashboard', icon: <LayoutDashboard /> },
{ href: '/admin/orgs', label: 'Organisations', icon: <Network /> },
{ href: '/admin/tenants', label: 'Chapters', icon: <Building2 /> },
{ href: '/admin/bots', label: 'Bot Pool', icon: <Server /> },
{ href: '/admin/drafts', label: 'AI Drafts', icon: <FileText /> },
];
export default function AdminAuthedLayout({ children }: { children: React.ReactNode }) {
const { admin, loading, logout } = useSuperAdmin();
const router = useRouter();
useEffect(() => {
if (!loading && !admin) router.replace('/admin/login');
}, [admin, loading, router]);
return (
<PortalShell
portalName="Super Admin"
theme="slate"
navItems={NAV}
loading={loading}
authed={!!admin}
user={admin ? { email: admin.email, name: admin.name, role: 'Super Admin' } : undefined}
onLogout={() => { void logout(); router.replace('/admin/login'); }}
>
{children}
</PortalShell>
);
}
@@ -2,7 +2,7 @@
import { use, useEffect, useState } from 'react'; import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useSuperAdmin } from '../../../_lib/super-admin-context'; import { useSuperAdmin } from '@/app/_lib/super-admin-context';
export default function AdminOrgDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function AdminOrgDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params); const { id } = use(params);
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context'; import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../_lib/super-admin-context'; import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../../_lib/super-admin-context'; import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
export default function TenantDetailPage() { export default function TenantDetailPage() {
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context'; import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
-3
View File
@@ -1,3 +0,0 @@
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+50 -43
View File
@@ -1,60 +1,67 @@
'use client'; 'use client';
import { FormEvent, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useSuperAdmin } from '../../_lib/super-admin-context'; import { useSuperAdmin } from '../../_lib/super-admin-context';
import { PortalLoginShell } from '../../_components/portal-login-shell';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../../_components/ui/form';
import { Input } from '../../_components/ui/input';
import { Button } from '../../_components/ui/button';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
});
type FormValues = z.infer<typeof schema>;
export default function SuperAdminLoginPage() { export default function SuperAdminLoginPage() {
const { login } = useSuperAdmin(); const { login } = useSuperAdmin();
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSubmit(e: FormEvent) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' } });
e.preventDefault();
setError(''); async function onSubmit(values: FormValues) {
setBusy(true);
try { try {
await login(email, password); await login(values.email, values.password);
router.replace('/admin'); router.replace('/admin');
} catch (err: any) { } catch (err) {
setError(err.message ?? 'Login failed'); form.setError('root', { message: err instanceof Error ? err.message : 'Login failed' });
} finally {
setBusy(false);
} }
} }
return ( return (
<div className="max-w-sm mx-auto mt-24"> <PortalLoginShell
<h1 className="text-xl font-bold mb-4">Super Admin Login</h1> title="Super Admin"
<form onSubmit={handleSubmit} className="flex flex-col gap-3"> subtitle="Platform-wide administration access."
<input theme="slate"
type="email" highlights={['Manage organisations & chapters', 'Provision and pair WhatsApp bots', 'Oversee the entire platform']}
placeholder="Email" >
value={email} <Form {...form}>
onChange={(e) => setEmail(e.target.value)} <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
required <FormField control={form.control} name="email" render={({ field }) => (
className="border rounded px-3 py-2 text-sm" <FormItem>
/> <FormLabel>Email</FormLabel>
<input <FormControl><Input type="email" placeholder="superadmin@tower.com" autoComplete="username" {...field} /></FormControl>
type="password" <FormMessage />
placeholder="Password" </FormItem>
value={password} )} />
onChange={(e) => setPassword(e.target.value)} <FormField control={form.control} name="password" render={({ field }) => (
required <FormItem>
className="border rounded px-3 py-2 text-sm" <FormLabel>Password</FormLabel>
/> <FormControl><Input type="password" autoComplete="current-password" {...field} /></FormControl>
{error && <p className="text-red-600 text-sm">{error}</p>} <FormMessage />
<button </FormItem>
type="submit" )} />
disabled={busy} {form.formState.errors.root && (
className="bg-blue-600 text-white rounded px-4 py-2 text-sm font-medium disabled:opacity-50" <p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
> )}
{busy ? 'Signing in...' : 'Sign in'} <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
</button> {form.formState.isSubmitting ? 'Signing in…' : 'Sign in'}
</form> </Button>
</div> </form>
</Form>
</PortalLoginShell>
); );
} }
@@ -0,0 +1,14 @@
import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const upstream = await fetch(`${getApiBaseUrl()}/public/auth/member-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await upstream.json().catch(() => ({})), upstream.status);
}
-19
View File
@@ -1,19 +0,0 @@
import { getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const upstream = await fetch(`${getApiBaseUrl()}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = await upstream.json();
if (!upstream.ok) return jsonResponse(payload, upstream.status);
const token: string | undefined = payload?.token;
if (!token) return jsonResponse({ message: 'Signup response missing token' }, 502);
const cookieValue = `tower_token=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 7}`;
return jsonResponse({ admin: payload.admin }, 200, { 'Set-Cookie': cookieValue });
}
@@ -3,11 +3,12 @@ import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function POST( export async function POST(
_req: Request, req: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
): Promise<Response> { ): Promise<Response> {
const { id } = await params; const { id } = await params;
const res = await apiFetch(`/admin/messages/${id}/approve`, { method: 'POST' }); const reqBody = await req.text().catch(() => '');
const res = await apiFetch(`/admin/messages/${id}/approve`, { method: 'POST', body: reqBody || undefined });
const body = await res.text(); const body = await res.text();
let payload: unknown = body; let payload: unknown = body;
try { try {
@@ -0,0 +1,15 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/messages/${id}/routes`);
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
@@ -0,0 +1,14 @@
import { getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const upstream = await fetch(`${getApiBaseUrl()}/public/auth/request-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await upstream.json().catch(() => ({})), upstream.status);
}
@@ -0,0 +1,21 @@
import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
async function getOrgToken(): Promise<string | undefined> {
const { cookies } = await import('next/headers');
return (await cookies()).get('tower_org_token')?.value;
}
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await params;
const token = await getOrgToken();
const body = await req.json().catch(() => ({}));
const res = await fetch(`${getApiBaseUrl()}/org/tenants/${id}/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await res.json(), res.status);
}
+14 -1
View File
@@ -1,7 +1,20 @@
import { apiFetch } from '../../../_lib/api'; import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await req.text();
const res = await apiFetch(`/routes/${id}`, { method: 'PATCH', body });
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
export async function DELETE( export async function DELETE(
_req: Request, _req: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
@@ -0,0 +1,26 @@
import { apiFetch, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string; tenantRuleId: string }> },
): Promise<Response> {
const { id, tenantRuleId } = await params;
const res = await apiFetch(`/routes/${id}/rules/${tenantRuleId}`, { method: 'POST' });
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string; tenantRuleId: string }> },
): Promise<Response> {
const { id, tenantRuleId } = await params;
const res = await apiFetch(`/routes/${id}/rules/${tenantRuleId}`, { method: 'DELETE' });
if (res.status === 204) return new Response(null, { status: 204 });
const body = await res.text();
return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
@@ -0,0 +1,15 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/routes/${id}/rules`);
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
+79
View File
@@ -1 +1,80 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.5rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 4px);
}
* {
border-color: var(--border);
outline-color: var(--ring);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
-179
View File
@@ -1,179 +0,0 @@
'use client';
import { useState } from 'react';
interface Group {
id: string;
name: string;
platform: string;
isActive: boolean;
}
interface Route {
id: string;
sourceGroupId: string;
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
}
export function RouteManager({
groups,
initialRoutes,
}: {
groups: Group[];
initialRoutes: Route[];
}) {
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
const [sourceId, setSourceId] = useState('');
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Group routes by source group name
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
for (const r of routes) {
const key = r.sourceGroup.name;
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
grouped.get(key)!.targets.push(r);
}
function toggleTarget(id: string) {
setTargetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
setError(null);
}
async function addRoutes() {
if (!sourceId || targetIds.size === 0) return;
setBusy(true);
setError(null);
try {
const res = await fetch('/api/routes/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
});
if (res.status === 409) {
const errBody = await res.json();
setError(errBody.message ?? 'Some routes already exist');
return;
}
if (!res.ok) {
setError('Failed to create routes');
return;
}
const created: Route[] = await res.json();
setRoutes((prev) => [...created, ...prev]);
setSourceId('');
setTargetIds(new Set());
} finally {
setBusy(false);
}
}
async function deleteRoute(id: string) {
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id));
}
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
return (
<div className="flex flex-col gap-6">
<section>
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
{routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p>
) : (
<ul className="flex flex-col gap-3">
{[...grouped.entries()].map(([sourceName, { sourceId: sId, targets }]) => (
<li key={sId} className="rounded-lg border border-gray-200 bg-white px-4 py-3">
<div className="flex items-start gap-3">
<span className="text-sm font-semibold text-gray-700 whitespace-nowrap mt-1 min-w-[120px]">{sourceName}</span>
<div className="flex flex-col gap-1.5 flex-1">
{targets.map((r) => (
<div key={r.id} className="flex items-center justify-between group">
<span className="text-sm text-gray-600">{r.targetGroup.name}</span>
<button
onClick={() => deleteRoute(r.id)}
className="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Delete route to ${r.targetGroup.name}`}
>
Delete
</button>
</div>
))}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="text-base font-semibold mb-3">Add routes</h2>
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center">
<select
value={sourceId}
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
aria-label="Source group"
>
<option value="">Source group</option>
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<span className="text-gray-400 text-sm"></span>
<span className="text-xs text-gray-500">
{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected
</span>
</div>
{sourceId && eligibleTargets.length > 0 && (
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
{eligibleTargets.map((g) => {
const checked = targetIds.has(g.id);
return (
<label
key={g.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
checked ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTarget(g.id)}
className="rounded border-gray-300"
/>
{g.name}
</label>
);
})}
</div>
)}
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
)}
<button
onClick={addRoutes}
disabled={!sourceId || targetIds.size === 0 || busy}
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
</button>
</div>
</section>
</div>
);
}
+11 -10
View File
@@ -1,25 +1,26 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css'; import './globals.css';
import { ReactQueryProvider } from './_lib/query-client';
import { AuthProvider } from './_lib/auth-context'; import { AuthProvider } from './_lib/auth-context';
import { SuperAdminProvider } from './_lib/super-admin-context'; import { SuperAdminProvider } from './_lib/super-admin-context';
import { Sidebar } from './_lib/sidebar'; import { OrgAdminProvider } from './_lib/org-admin-context';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Insignia TOWER', title: 'TOWER',
description: 'Community Knowledge Infrastructure Platform', description: 'Community Knowledge Infrastructure Platform',
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body className="flex min-h-screen bg-gray-50 text-gray-900 antialiased"> <body className="min-h-screen bg-background text-foreground antialiased">
<AuthProvider> <ReactQueryProvider>
<SuperAdminProvider> <AuthProvider>
<Sidebar /> <SuperAdminProvider>
<main className="flex-1 overflow-auto p-6">{children}</main> <OrgAdminProvider>{children}</OrgAdminProvider>
</SuperAdminProvider> </SuperAdminProvider>
</AuthProvider> </AuthProvider>
</ReactQueryProvider>
</body> </body>
</html> </html>
); );
+64 -91
View File
@@ -1,113 +1,86 @@
'use client'; 'use client';
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useAuth } from '../_lib/auth-context'; import { useAuth } from '../_lib/auth-context';
import { useSuperAdmin } from '../_lib/super-admin-context'; import { PortalLoginShell } from '../_components/portal-login-shell';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../_components/ui/form';
import { Input } from '../_components/ui/input';
import { Button } from '../_components/ui/button';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
});
type FormValues = z.infer<typeof schema>;
function LoginForm() { function LoginForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { refresh } = useAuth(); const { refresh } = useAuth();
const { admin: superAdmin, loading: superLoading } = useSuperAdmin(); const next = searchParams.get('next') ?? '/search';
const next = searchParams.get('next') ?? '/';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redirecting, setRedirecting] = useState(false);
useEffect(() => { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' } });
if (!superLoading && superAdmin) {
setRedirecting(true); async function onSubmit(values: FormValues) {
router.replace('/admin'); const res = await fetch('/api/auth/login', {
} method: 'POST',
}, [superAdmin, superLoading, router]); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
if (superLoading) { credentials: 'include',
return <div className="bg-white p-6 rounded-xl border border-gray-200 h-64 animate-pulse" />; });
} if (!res.ok) {
const data = await res.json().catch(() => ({})) as { message?: string };
if (redirecting) { form.setError('root', { message: data.message ?? 'Invalid email or password' });
return <p className="text-sm text-gray-500">Redirecting to admin panel</p>; return;
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data?.message ?? 'Invalid email or password');
return;
}
await refresh();
router.push(next);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Network error');
} finally {
setSubmitting(false);
} }
await refresh();
router.push(next);
router.refresh();
} }
return ( return (
<form onSubmit={onSubmit} className="flex flex-col gap-4 bg-white p-6 rounded-xl border border-gray-200"> <Form {...form}>
<label className="flex flex-col gap-1 text-sm"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<span className="font-medium text-gray-700">Email</span> <FormField control={form.control} name="email" render={({ field }) => (
<input <FormItem>
type="email" <FormLabel>Email</FormLabel>
value={email} <FormControl><Input type="email" placeholder="admin@chapter.com" autoComplete="username" {...field} /></FormControl>
onChange={(e) => setEmail(e.target.value)} <FormMessage />
required </FormItem>
autoComplete="username" )} />
className="rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200" <FormField control={form.control} name="password" render={({ field }) => (
/> <FormItem>
</label> <FormLabel>Password</FormLabel>
<label className="flex flex-col gap-1 text-sm"> <FormControl><Input type="password" autoComplete="current-password" {...field} /></FormControl>
<span className="font-medium text-gray-700">Password</span> <FormMessage />
<input </FormItem>
type="password" )} />
value={password} {form.formState.errors.root && (
onChange={(e) => setPassword(e.target.value)} <p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
required )}
autoComplete="current-password" <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
className="rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200" {form.formState.isSubmitting ? 'Signing in…' : 'Sign in'}
/> </Button>
</label> </form>
{error && <p className="text-sm text-red-600" role="alert">{error}</p>} </Form>
<button
type="submit"
disabled={submitting}
className="rounded bg-blue-600 text-white py-2 font-medium hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
); );
} }
export default function LoginPage() { export default function ChapterLoginPage() {
return ( return (
<div className="max-w-sm mx-auto mt-16"> <PortalLoginShell
<h1 className="text-2xl font-semibold mb-2">Sign in</h1> title="Chapter Portal"
<p className="text-sm text-gray-500 mb-6">TOWER administrative console</p> subtitle="Sign in to manage your chapter's messages and groups."
<Suspense fallback={<div className="bg-white p-6 rounded-xl border border-gray-200 h-64" />}> theme="blue"
highlights={['Approve & route group messages', 'Configure hashtag and prefix rules', 'Review AI-generated digests']}
>
<Suspense>
<LoginForm /> <LoginForm />
</Suspense> </Suspense>
<p className="text-sm text-gray-500 mt-6 text-center"> </PortalLoginShell>
New here?{' '}
<a href="/signup" className="text-blue-600 hover:underline">
Create a community
</a>
</p>
</div>
); );
} }
+101 -124
View File
@@ -2,144 +2,121 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { PortalLoginShell } from '../_components/portal-login-shell';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../_components/ui/form';
import { Input } from '../_components/ui/input';
import { Button } from '../_components/ui/button';
import Link from 'next/link';
const API_BASE = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001'; const phoneSchema = z.object({ phone: z.string().min(8, 'Enter your WhatsApp number') });
const otpSchema = z.object({ code: z.string().length(6, 'Enter the 6-digit code') });
type Step = 'phone' | 'code'; type PhoneValues = z.infer<typeof phoneSchema>;
type OtpValues = z.infer<typeof otpSchema>;
export default function MemberLoginPage() { export default function MemberLoginPage() {
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<Step>('phone'); const [step, setStep] = useState<'phone' | 'otp'>('phone');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [challengeId, setChallengeId] = useState(''); const [challengeId, setChallengeId] = useState('');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function requestOtp() { const phoneForm = useForm<PhoneValues>({ resolver: zodResolver(phoneSchema), defaultValues: { phone: '' } });
setError(null); const otpForm = useForm<OtpValues>({ resolver: zodResolver(otpSchema), defaultValues: { code: '' } });
setBusy(true);
try { async function onPhone(values: PhoneValues) {
const res = await fetch(`${API_BASE}/public/auth/member-login`, { const res = await fetch('/api/auth/member/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone }), body: JSON.stringify({ phone: values.phone }),
}); });
const data = (await res.json().catch(() => ({}))) as { challengeId?: string; message?: string }; const data = await res.json().catch(() => ({})) as { challengeId?: string; message?: string };
if (!res.ok) { if (!res.ok) { phoneForm.setError('root', { message: data.message ?? 'Failed to send code' }); return; }
setError(data.message ?? 'Failed to send code'); setPhone(values.phone);
return; setChallengeId(data.challengeId ?? '');
} setStep('otp');
setChallengeId(data.challengeId ?? '');
setStep('code');
} finally {
setBusy(false);
}
} }
async function verifyOtp() { async function onOtp(values: OtpValues) {
setError(null); const res = await fetch('/api/my/login', {
setBusy(true); method: 'POST',
try { headers: { 'Content-Type': 'application/json' },
const res = await fetch('/api/my/login', { body: JSON.stringify({ challengeId, phone, code: values.code }),
method: 'POST', });
headers: { 'Content-Type': 'application/json' }, const data = await res.json().catch(() => ({})) as { message?: string };
body: JSON.stringify({ challengeId, phone, code }), if (!res.ok) { otpForm.setError('root', { message: data.message ?? 'Verification failed' }); return; }
}); router.push('/my');
const data = (await res.json().catch(() => ({}))) as { message?: string };
if (!res.ok) {
setError(data.message ?? 'Verification failed');
return;
}
router.push('/my');
} finally {
setBusy(false);
}
} }
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4"> <PortalLoginShell
<div className="w-full max-w-sm bg-white rounded-2xl border border-gray-200 shadow-sm p-7 space-y-6"> title="Member Portal"
<div> subtitle={step === 'phone' ? 'Enter your WhatsApp number to receive a sign-in code.' : `We sent a 6-digit code to ${phone} on WhatsApp.`}
<div className="w-10 h-10 rounded-xl bg-indigo-100 flex items-center justify-center text-xl mb-4">🏠</div> theme="emerald"
<h1 className="text-lg font-semibold text-gray-900">Sign in to your portal</h1> highlights={['Read your community digests', 'Manage privacy & consent', 'Discover events and members']}
<p className="text-sm text-gray-500 mt-1"> footer={
{step === 'phone' step === 'phone'
? 'Enter your WhatsApp number and we\'ll send you a code.' ? <Link href="/onboard" className="text-emerald-600 hover:underline">First time? Use your invite link</Link>
: `We sent a 6-digit code to ${phone} on WhatsApp.`} : undefined
</p> }
</div> >
{step === 'phone' ? (
{step === 'phone' && ( <Form {...phoneForm}>
<div className="space-y-4"> <form onSubmit={phoneForm.handleSubmit(onPhone)} className="space-y-4">
<input <FormField control={phoneForm.control} name="phone" render={({ field }) => (
type="tel" <FormItem>
value={phone} <FormLabel>WhatsApp number</FormLabel>
onChange={(e) => setPhone(e.target.value)} <FormControl><Input type="tel" placeholder="+91 98765 43210" autoFocus {...field} /></FormControl>
placeholder="+91 98765 43210" <FormMessage />
autoFocus </FormItem>
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" )} />
/> {phoneForm.formState.errors.root && (
{error && <p className="text-sm text-red-600">{error}</p>} <p className="text-sm text-destructive">{phoneForm.formState.errors.root.message}</p>
<button )}
type="button" <Button type="submit" className="w-full" disabled={phoneForm.formState.isSubmitting}>
onClick={requestOtp} {phoneForm.formState.isSubmitting ? 'Sending…' : 'Send code'}
disabled={busy || phone.replace(/\D/g, '').length < 8} </Button>
className="w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors" </form>
> </Form>
{busy ? 'Sending…' : 'Send code'} ) : (
</button> <Form {...otpForm}>
<p className="text-xs text-center text-gray-400"> <form onSubmit={otpForm.handleSubmit(onOtp)} className="space-y-4">
First time?{' '} <FormField control={otpForm.control} name="code" render={({ field }) => (
<a href="/onboard" className="text-indigo-600 hover:underline"> <FormItem>
Use your invite link <FormLabel>6-digit code</FormLabel>
</a> <FormControl>
</p> <Input
</div> inputMode="numeric"
)} pattern="[0-9]{6}"
maxLength={6}
{step === 'code' && ( placeholder="000000"
<div className="space-y-4"> className="text-center text-xl tracking-[0.5em] font-mono"
<input autoFocus
type="text" {...field}
inputMode="numeric" onChange={(e) => field.onChange(e.target.value.replace(/\D/g, ''))}
pattern="[0-9]{6}" />
maxLength={6} </FormControl>
value={code} <FormMessage />
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))} </FormItem>
placeholder="000000" )} />
autoFocus {otpForm.formState.errors.root && (
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center text-lg tracking-[0.3em] font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500" <p className="text-sm text-destructive">{otpForm.formState.errors.root.message}</p>
/> )}
<button
type="button"
onClick={requestOtp}
disabled={busy}
className="text-xs text-indigo-600 hover:underline disabled:opacity-50 w-full text-center"
>
Didn't get it? Resend code
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button type="button" variant="outline" onClick={() => { setStep('phone'); otpForm.reset(); }} className="flex-none">
type="button"
onClick={() => { setStep('phone'); setCode(''); setError(null); }}
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
>
Back Back
</button> </Button>
<button <Button type="submit" className="flex-1" disabled={otpForm.formState.isSubmitting}>
type="button" {otpForm.formState.isSubmitting ? 'Verifying…' : 'Sign in'}
onClick={verifyOtp} </Button>
disabled={busy || code.length < 6}
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{busy ? 'Verifying' : 'Sign in'}
</button>
</div> </div>
</div> <Button type="button" variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={() => void onPhone({ phone })}>
)} Resend code
</div> </Button>
</div> </form>
</Form>
)}
</PortalLoginShell>
); );
} }
-105
View File
@@ -1,105 +0,0 @@
import { apiFetch } from '../../_lib/api';
interface PendingMessage {
id: string;
content: string;
senderJid: string;
senderName: string | null;
tags: string[];
createdAt: string;
sourceGroupId: string;
sourceGroupName: string;
sourceGroupPlatformId: string;
}
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
async function fetchPending(): Promise<FetchResult<PendingMessage[]>> {
try {
const res = await apiFetch('/admin/messages/pending');
if (!res.ok) {
const body = await res.text().catch(() => '');
return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText };
}
return { ok: true, data: (await res.json()) as PendingMessage[] };
} catch (err) {
return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` };
}
}
export default async function PendingMessagesPage() {
const result = await fetchPending();
const messages = result.ok ? result.data : [];
const error = !result.ok ? (result.status === 0 ? 'API unreachable' : `API ${result.status}: ${result.error}`) : null;
return (
<div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-2">Pending messages</h1>
<p className="text-sm text-gray-500 mb-6">
Flagged messages waiting for an admin to approve. Approving forwards them to every active
route from the source group and indexes them in search.
</p>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
Failed to load: {error}
</div>
)}
{!error && messages.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
No pending messages right now. New flagged messages will appear here as they arrive.
</div>
)}
<ul className="space-y-3">
{messages.map((m) => (
<PendingMessageRow key={m.id} message={m} />
))}
</ul>
</div>
);
}
function PendingMessageRow({ message }: { message: PendingMessage }) {
return (
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
<div className="text-xs text-gray-500">
{new Date(message.createdAt).toLocaleString()}
</div>
</div>
<div className="text-xs text-gray-500">
From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
{message.tags.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{message.tags.map((t) => (
<span
key={t}
className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700"
>
{t}
</span>
))}
</span>
)}
</div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">
{message.content}
</div>
<form
action={`/api/messages/${message.id}/approve`}
method="post"
className="flex justify-end pt-1"
>
<button
type="submit"
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Approve &amp; forward
</button>
</form>
</li>
);
}
+22 -59
View File
@@ -1,71 +1,34 @@
import Link from 'next/link'; import { getMemberToken, getApiBaseUrl } from '../_lib/api';
import { getMemberToken } from '../_lib/api';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { MemberNav } from './member-nav';
const NAV = [ interface MemberScope {
{ href: '/my', label: 'Dashboard' }, member: { displayName: string | null };
{ href: '/my/ask', label: 'Ask AI' }, chapter: { id: string; slug: string; name: string };
{ href: '/my/knowledge', label: 'Knowledge' }, organization: { id: string; slug: string; name: string } | null;
{ href: '/my/digest', label: 'Digest' }, }
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' }, async function fetchScope(token: string): Promise<MemberScope | null> {
{ href: '/my/circles', label: 'Circles' }, const res = await fetch(`${getApiBaseUrl()}/my/scope`, {
{ href: '/my/directory', label: 'Directory' }, headers: { Authorization: `Bearer ${token}` },
{ href: '/my/memories', label: 'Memories' }, cache: 'no-store',
{ href: '/my/groups', label: 'My Groups' }, }).catch(() => null);
{ href: '/my/settings', label: 'Settings' }, if (!res || !res.ok) return null;
]; return (await res.json()) as MemberScope;
}
export default async function MemberLayout({ children }: { children: React.ReactNode }) { export default async function MemberLayout({ children }: { children: React.ReactNode }) {
const token = await getMemberToken(); const token = await getMemberToken();
if (!token) redirect('/member-login'); if (!token) redirect('/member-login');
const scope = await fetchScope(token);
return ( return (
<div className="flex h-full -m-6 min-h-screen"> <div className="flex h-screen overflow-hidden">
{/* Left sidebar */} <MemberNav scope={scope} />
<nav className="w-[220px] shrink-0 bg-white border-r border-gray-200 flex flex-col p-4"> <main className="flex-1 overflow-auto bg-muted/30">
<span className="font-bold text-base mb-6 px-2">TOWER</span> <div className="p-6 lg:p-8 max-w-6xl mx-auto w-full">{children}</div>
<div className="flex flex-col gap-1 flex-1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
{item.label}
</Link>
))}
</div>
<div className="border-t border-gray-200 pt-3 mt-3">
<p className="px-3 text-xs text-gray-400 uppercase tracking-wide">Member portal</p>
</div>
</nav>
{/* Main content */}
<main className="flex-1 overflow-auto p-6 bg-gray-50">
{children}
</main> </main>
{/* Right activity rail */}
<aside className="w-[280px] shrink-0 bg-white border-l border-gray-200 p-4 hidden lg:block">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-4">Quick actions</p>
<div className="flex flex-col gap-2">
<Link
href="/my/settings"
className="text-sm text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md hover:bg-gray-50"
>
Privacy settings
</Link>
<form method="POST" action="/api/my/logout">
<button
type="submit"
className="w-full text-left text-sm text-red-500 hover:text-red-700 px-3 py-2 rounded-md hover:bg-red-50"
>
Sign out
</button>
</form>
</div>
</aside>
</div> </div>
); );
} }
+87
View File
@@ -0,0 +1,87 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '../_lib/utils';
import { Button } from '../_components/ui/button';
import { ScopeCard } from '../_components/scope-card';
import { PORTAL_THEMES } from '../_components/portal-theme';
import {
LayoutDashboard, Newspaper, Calendar, HeartHandshake, Users2,
BookOpen, Sparkles, Images, Contact, Settings, LogOut,
} from 'lucide-react';
interface MemberScope {
member: { displayName: string | null };
chapter: { id: string; slug: string; name: string };
organization: { id: string; slug: string; name: string } | null;
}
const NAV = [
{ href: '/my', label: 'Dashboard', icon: <LayoutDashboard /> },
{ href: '/my/digest', label: 'Digest', icon: <Newspaper /> },
{ href: '/my/ask', label: 'Ask AI', icon: <Sparkles /> },
{ href: '/my/events', label: 'Events', icon: <Calendar /> },
{ href: '/my/seva', label: 'Seva & Points', icon: <HeartHandshake /> },
{ href: '/my/circles', label: 'Circles', icon: <Users2 /> },
{ href: '/my/directory', label: 'Directory', icon: <Contact /> },
{ href: '/my/knowledge', label: 'Knowledge', icon: <BookOpen /> },
{ href: '/my/memories', label: 'Memories', icon: <Images /> },
{ href: '/my/groups', label: 'My Groups', icon: <Users2 /> },
{ href: '/my/settings', label: 'Settings', icon: <Settings /> },
];
export function MemberNav({ scope }: { scope?: MemberScope | null }) {
const pathname = usePathname();
const t = PORTAL_THEMES.emerald;
async function logout() {
await fetch('/api/my/logout', { method: 'POST' });
window.location.href = '/member-login';
}
return (
<aside className="w-60 shrink-0 flex flex-col border-r bg-sidebar">
<div className="h-16 flex items-center gap-3 px-5 border-b">
<div className={cn('size-8 rounded-lg flex items-center justify-center text-white font-bold text-sm shadow-sm', t.gradient)}>
T
</div>
<div className="flex flex-col leading-none">
<span className="font-semibold text-sm tracking-tight">TOWER</span>
<span className="text-[11px] text-muted-foreground mt-0.5">Member</span>
</div>
</div>
{scope && (
<ScopeCard accent="text-emerald-600" organization={scope.organization} chapter={scope.chapter} />
)}
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV.map((item) => {
const isActive = item.href === '/my' ? pathname === '/my' : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive ? cn(t.activeBg, t.activeText, 'font-medium') : 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{isActive && <span className={cn('absolute left-0 top-1/2 -translate-y-1/2 h-5 w-1 rounded-r-full', t.bar)} />}
<span className="size-4 shrink-0 [&_svg]:size-4">{item.icon}</span>
{item.label}
</Link>
);
})}
</nav>
<div className="p-3 border-t">
<Button variant="ghost" size="sm" className="w-full justify-start text-muted-foreground" onClick={() => void logout()}>
<LogOut className="size-4" />
Sign out
</Button>
</div>
</aside>
);
}
+1 -3
View File
@@ -43,8 +43,6 @@ const SCOPE_META: ScopeMeta[] = [
}, },
]; ];
const API_BASE = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001';
export function OnboardingForm({ token, groupName, tenantName, defaultScopes, defaultRetentionDays }: Props) { export function OnboardingForm({ token, groupName, tenantName, defaultScopes, defaultRetentionDays }: Props) {
const [step, setStep] = useState<Step>('welcome'); const [step, setStep] = useState<Step>('welcome');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
@@ -62,7 +60,7 @@ export function OnboardingForm({ token, groupName, tenantName, defaultScopes, de
setError(null); setError(null);
setBusy(true); setBusy(true);
try { try {
const res = await fetch(`${API_BASE}/public/auth/request-otp`, { const res = await fetch('/api/onboard/request-otp', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ onboardingToken: token, phone }), body: JSON.stringify({ onboardingToken: token, phone }),
+36
View File
@@ -0,0 +1,36 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useOrgAdmin } from '@/app/_lib/org-admin-context';
import { PortalShell } from '@/app/_components/portal-shell';
import { LayoutDashboard, Building2, Shield } from 'lucide-react';
const NAV = [
{ href: '/org', label: 'Dashboard', icon: <LayoutDashboard /> },
{ href: '/org/tenants', label: 'Chapters', icon: <Building2 /> },
{ href: '/org/rules', label: 'Org Rules', icon: <Shield /> },
];
export default function OrgAuthedLayout({ children }: { children: React.ReactNode }) {
const { orgAdmin, loading, logout } = useOrgAdmin();
const router = useRouter();
useEffect(() => {
if (!loading && !orgAdmin) router.replace('/org/login');
}, [orgAdmin, loading, router]);
return (
<PortalShell
portalName="Organisation"
theme="violet"
navItems={NAV}
loading={loading}
authed={!!orgAdmin}
user={orgAdmin ? { email: orgAdmin.email, name: orgAdmin.name, role: orgAdmin.role, subtitle: orgAdmin.organization?.name } : undefined}
onLogout={() => { void logout(); router.replace('/org/login'); }}
>
{children}
</PortalShell>
);
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useOrgAdmin } from '../_lib/org-admin-context'; import { useOrgAdmin } from '@/app/_lib/org-admin-context';
export default function OrgDashboardPage() { export default function OrgDashboardPage() {
const { orgAdmin, loading } = useOrgAdmin(); const { orgAdmin, loading } = useOrgAdmin();
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useOrgAdmin } from '../../_lib/org-admin-context'; import { useOrgAdmin } from '@/app/_lib/org-admin-context';
const MATCH_TYPES = ['HASHTAG', 'PREFIX', 'REACTION_EMOJI']; const MATCH_TYPES = ['HASHTAG', 'PREFIX', 'REACTION_EMOJI'];
const ACTIONS = ['FLAG', 'AUTO_APPROVE', 'SKIP', 'REJECT', 'P1']; const ACTIONS = ['FLAG', 'AUTO_APPROVE', 'SKIP', 'REJECT', 'P1'];
@@ -0,0 +1,137 @@
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useOrgAdmin } from '@/app/_lib/org-admin-context';
export default function OrgTenantDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { orgAdmin, loading } = useOrgAdmin();
const router = useRouter();
const [tenant, setTenant] = useState<any>(null);
const [showAdminForm, setShowAdminForm] = useState(false);
const [adminForm, setAdminForm] = useState({ email: '', password: '', role: 'ADMIN' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (loading) return;
if (!orgAdmin) { router.replace('/org/login'); return; }
fetch(`/api/org/tenants/${id}`)
.then((r) => r.ok ? r.json() : null)
.then((d) => setTenant(d))
.catch(() => {});
}, [orgAdmin, loading, router, id]);
async function handleCreateAdmin(e: React.FormEvent) {
e.preventDefault();
setError('');
setSaving(true);
try {
const res = await fetch(`/api/org/tenants/${id}/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(adminForm),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(Array.isArray(err.message) ? err.message.join(', ') : (err.message ?? 'Failed'));
}
setShowAdminForm(false);
setAdminForm({ email: '', password: '', role: 'ADMIN' });
const updated = await fetch(`/api/org/tenants/${id}`).then((r) => r.ok ? r.json() : null);
if (updated) setTenant(updated);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setSaving(false);
}
}
if (loading || !orgAdmin) return <p className="text-gray-500">Loading...</p>;
if (!tenant) return <p className="text-gray-400">Chapter not found.</p>;
return (
<div>
<div className="mb-6">
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-3"> Back</button>
<h1 className="text-2xl font-bold">{tenant.name}</h1>
<p className="text-sm text-gray-500">{tenant.slug}</p>
</div>
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ label: 'Messages', value: tenant._count?.messages },
{ label: 'Members', value: tenant._count?.towerUsers },
{ label: 'Groups', value: tenant._count?.groups },
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border p-5">
<p className="text-2xl font-bold">{stat.value ?? '—'}</p>
<p className="text-sm text-gray-500">{stat.label}</p>
</div>
))}
</div>
<div className="bg-white rounded-xl border overflow-hidden">
<div className="px-5 py-4 border-b flex items-center justify-between">
<h2 className="font-semibold">Chapter Admins</h2>
<button
onClick={() => setShowAdminForm(!showAdminForm)}
className="bg-blue-600 text-white rounded-lg px-3 py-1.5 text-sm font-medium hover:bg-blue-700"
>
Add Admin
</button>
</div>
{showAdminForm && (
<form onSubmit={(e) => void handleCreateAdmin(e)} className="p-5 border-b flex flex-col gap-3">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={adminForm.email} onChange={(e) => setAdminForm({ ...adminForm, email: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" required />
</div>
<div>
<label className="block text-xs font-medium mb-1">Password</label>
<input type="password" value={adminForm.password} onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" required />
</div>
<div>
<label className="block text-xs font-medium mb-1">Role</label>
<select value={adminForm.role} onChange={(e) => setAdminForm({ ...adminForm, role: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="VIEWER">VIEWER</option>
</select>
</div>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={saving} className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50">{saving ? 'Saving...' : 'Create'}</button>
<button type="button" onClick={() => { setShowAdminForm(false); setError(''); }} className="border rounded-lg px-4 py-2 text-sm hover:bg-gray-50">Cancel</button>
</div>
</form>
)}
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Joined</th>
</tr>
</thead>
<tbody className="divide-y">
{(tenant.admins ?? []).map((a: any) => (
<tr key={a.id} className="hover:bg-gray-50">
<td className="px-4 py-3">{a.email}</td>
<td className="px-4 py-3">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-gray-100 text-gray-700">{a.role}</span>
</td>
<td className="px-4 py-3 text-gray-500">{new Date(a.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
{tenant.admins?.length === 0 && <p className="p-4 text-gray-400">No admins yet.</p>}
</div>
</div>
);
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useOrgAdmin } from '../../_lib/org-admin-context'; import { useOrgAdmin } from '@/app/_lib/org-admin-context';
export default function OrgTenantsPage() { export default function OrgTenantsPage() {
const { orgAdmin, loading } = useOrgAdmin(); const { orgAdmin, loading } = useOrgAdmin();
-3
View File
@@ -1,3 +0,0 @@
export default function OrgLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+48 -48
View File
@@ -1,67 +1,67 @@
'use client'; 'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useOrgAdmin } from '../../_lib/org-admin-context'; import { useOrgAdmin } from '../../_lib/org-admin-context';
import { PortalLoginShell } from '../../_components/portal-login-shell';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../../_components/ui/form';
import { Input } from '../../_components/ui/input';
import { Button } from '../../_components/ui/button';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
});
type FormValues = z.infer<typeof schema>;
export default function OrgLoginPage() { export default function OrgLoginPage() {
const { login } = useOrgAdmin(); const { login } = useOrgAdmin();
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' } });
e.preventDefault();
setError(''); async function onSubmit(values: FormValues) {
setLoading(true);
try { try {
await login(email, password); await login(values.email, values.password);
router.replace('/org'); router.replace('/org');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed'); form.setError('root', { message: err instanceof Error ? err.message : 'Login failed' });
} finally {
setLoading(false);
} }
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <PortalLoginShell
<div className="bg-white rounded-xl border p-8 w-full max-w-sm"> title="Organisation Portal"
<h1 className="text-2xl font-bold mb-1">TOWER</h1> subtitle="Manage your chapters, admins and org-wide rules."
<p className="text-sm text-gray-500 mb-6">Organization portal</p> theme="violet"
<form onSubmit={(e) => void handleSubmit(e)} className="flex flex-col gap-4"> highlights={['Create and oversee chapters', 'Set org-wide routing rules', 'Provision chapter admins']}
<div> >
<label className="block text-sm font-medium mb-1">Email</label> <Form {...form}>
<input <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
type="email" <FormField control={form.control} name="email" render={({ field }) => (
value={email} <FormItem>
onChange={(e) => setEmail(e.target.value)} <FormLabel>Email</FormLabel>
className="w-full border rounded-lg px-3 py-2 text-sm" <FormControl><Input type="email" placeholder="admin@org.com" autoComplete="username" {...field} /></FormControl>
required <FormMessage />
/> </FormItem>
</div> )} />
<div> <FormField control={form.control} name="password" render={({ field }) => (
<label className="block text-sm font-medium mb-1">Password</label> <FormItem>
<input <FormLabel>Password</FormLabel>
type="password" <FormControl><Input type="password" autoComplete="current-password" {...field} /></FormControl>
value={password} <FormMessage />
onChange={(e) => setPassword(e.target.value)} </FormItem>
className="w-full border rounded-lg px-3 py-2 text-sm" )} />
required {form.formState.errors.root && (
/> <p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
</div> )}
{error && <p className="text-red-600 text-sm">{error}</p>} <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
<button {form.formState.isSubmitting ? 'Signing in…' : 'Sign in'}
type="submit" </Button>
disabled={loading}
className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form> </form>
</div> </Form>
</div> </PortalLoginShell>
); );
} }
-74
View File
@@ -1,74 +0,0 @@
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useOrgAdmin } from '../../../_lib/org-admin-context';
export default function OrgTenantDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { orgAdmin, loading } = useOrgAdmin();
const router = useRouter();
const [tenant, setTenant] = useState<any>(null);
useEffect(() => {
if (loading) return;
if (!orgAdmin) { router.replace('/org/login'); return; }
fetch(`/api/org/tenants/${id}`)
.then((r) => r.ok ? r.json() : null)
.then((d) => setTenant(d))
.catch(() => {});
}, [orgAdmin, loading, router, id]);
if (loading || !orgAdmin) return <p className="text-gray-500">Loading...</p>;
if (!tenant) return <p className="text-gray-400">Chapter not found.</p>;
return (
<div>
<div className="mb-6">
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-3"> Back</button>
<h1 className="text-2xl font-bold">{tenant.name}</h1>
<p className="text-sm text-gray-500">{tenant.slug}</p>
</div>
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ label: 'Messages', value: tenant._count?.messages },
{ label: 'Members', value: tenant._count?.towerUsers },
{ label: 'Groups', value: tenant._count?.groups },
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border p-5">
<p className="text-2xl font-bold">{stat.value ?? '—'}</p>
<p className="text-sm text-gray-500">{stat.label}</p>
</div>
))}
</div>
<div className="bg-white rounded-xl border overflow-hidden">
<div className="px-5 py-4 border-b">
<h2 className="font-semibold">Chapter Admins</h2>
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Joined</th>
</tr>
</thead>
<tbody className="divide-y">
{(tenant.admins ?? []).map((a: any) => (
<tr key={a.id} className="hover:bg-gray-50">
<td className="px-4 py-3">{a.email}</td>
<td className="px-4 py-3">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-gray-100 text-gray-700">{a.role}</span>
</td>
<td className="px-4 py-3 text-gray-500">{new Date(a.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
{tenant.admins?.length === 0 && <p className="p-4 text-gray-400">No admins yet.</p>}
</div>
</div>
);
}
+65 -18
View File
@@ -1,25 +1,72 @@
import Link from 'next/link'; import Link from 'next/link';
import { ArrowRight, Building2, Network, UserRound } from 'lucide-react';
const PORTALS = [
{
href: '/org/login',
title: 'Organisation',
description: 'Manage chapters, org-wide rules and admin access.',
icon: Network,
iconWrap: 'bg-violet-100 text-violet-600',
ring: 'hover:ring-violet-200 hover:border-violet-300',
},
{
href: '/login',
title: 'Chapter',
description: 'Approve messages, manage groups and routing rules.',
icon: Building2,
iconWrap: 'bg-blue-100 text-blue-600',
ring: 'hover:ring-blue-200 hover:border-blue-300',
},
{
href: '/member-login',
title: 'Member',
description: 'View digests, manage privacy and explore your community.',
icon: UserRound,
iconWrap: 'bg-emerald-100 text-emerald-600',
ring: 'hover:ring-emerald-200 hover:border-emerald-300',
},
];
export default function Home() { export default function Home() {
return ( return (
<div className="flex flex-col gap-6 max-w-xl"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-6">
<h1 className="text-3xl font-bold tracking-tight">Insignia TOWER</h1> <div className="w-full max-w-md space-y-10">
<p className="text-gray-500">Community Knowledge Infrastructure Platform</p> <div className="text-center space-y-3">
<div className="flex gap-4"> <div className="inline-flex size-12 rounded-2xl bg-gradient-to-br from-slate-700 to-slate-900 items-center justify-center text-white font-bold text-xl shadow-lg mx-auto">
<Link T
href="/search" </div>
className="flex-1 rounded-xl border border-gray-200 bg-white p-5 hover:border-blue-400 transition-colors" <div className="space-y-1">
> <h1 className="text-2xl font-bold tracking-tight">Welcome to TOWER</h1>
<h2 className="font-semibold mb-1">Search</h2> <p className="text-sm text-muted-foreground">Choose how you want to sign in</p>
<p className="text-sm text-gray-500">Full-text search of approved messages</p> </div>
</Link> </div>
<Link
href="/groups" <div className="space-y-3">
className="flex-1 rounded-xl border border-gray-200 bg-white p-5 hover:border-blue-400 transition-colors" {PORTALS.map((p) => {
> const Icon = p.icon;
<h2 className="font-semibold mb-1">Groups</h2> return (
<p className="text-sm text-gray-500">Manage groups and sync routes</p> <Link
</Link> key={p.href}
href={p.href}
className={`group flex items-center gap-4 bg-card rounded-2xl border p-4 shadow-sm ring-1 ring-transparent transition-all hover:shadow-md ${p.ring}`}
>
<div className={`size-11 rounded-xl flex items-center justify-center shrink-0 ${p.iconWrap}`}>
<Icon className="size-5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm">{p.title} Portal</p>
<p className="text-xs text-muted-foreground leading-relaxed mt-0.5">{p.description}</p>
</div>
<ArrowRight className="size-4 text-muted-foreground shrink-0 transition-transform group-hover:translate-x-0.5" />
</Link>
);
})}
</div>
<p className="text-center text-xs text-muted-foreground">
Access is provisioned by your organisation admin
</p>
</div> </div>
</div> </div>
); );
-142
View File
@@ -1,142 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useAuth } from '../_lib/auth-context';
function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
export function SignupForm({ redirect }: { redirect?: string }) {
const router = useRouter();
const { refresh } = useAuth();
const [tenantName, setTenantName] = useState('');
const [tenantSlug, setTenantSlug] = useState('');
const [slugTouched, setSlugTouched] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const computedSlug = slugify(tenantName);
const effectiveSlug = slugTouched ? tenantSlug : computedSlug;
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (effectiveSlug.length < 2) {
setError('Tenant name must produce a valid slug (lowercase letters, digits, dashes)');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantName, tenantSlug: effectiveSlug, email, password }),
credentials: 'include',
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { message?: string };
const messages = Array.isArray((data as { message?: unknown }).message)
? ((data as unknown as { message: string[] }).message.join(', '))
: data.message;
setError(messages ?? 'Signup failed');
return;
}
await refresh();
router.push(redirect || '/');
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-3">
<label className="block text-sm">
<span className="font-medium">Community / organization name</span>
<input
type="text"
value={tenantName}
onChange={(e) => setTenantName(e.target.value)}
placeholder="Delhi Traders"
required
className="mt-1 w-full border rounded px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="font-medium">URL slug</span>
<input
type="text"
value={effectiveSlug}
onChange={(e) => {
setSlugTouched(true);
setTenantSlug(slugify(e.target.value));
}}
placeholder="delhi"
required
className="mt-1 w-full border rounded px-3 py-2 font-mono text-sm"
/>
<span className="block text-xs text-gray-500 mt-1">
Lowercase letters, digits, dashes. 240 chars.
</span>
</label>
<label className="block text-sm">
<span className="font-medium">Your email</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
className="mt-1 w-full border rounded px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="font-medium">Password</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
minLength={8}
required
className="mt-1 w-full border rounded px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="font-medium">Confirm password</span>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
minLength={8}
required
className="mt-1 w-full border rounded px-3 py-2"
/>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{submitting ? 'Creating account…' : 'Create community'}
</button>
<p className="text-xs text-gray-500 text-center">
You&apos;ll be the first OWNER. You can invite others from settings later.
</p>
</form>
);
}
-24
View File
@@ -1,24 +0,0 @@
import { SignupForm } from './SignupForm';
export default async function SignupPage({
searchParams,
}: {
searchParams: Promise<{ redirect?: string }>;
}) {
const { redirect } = await searchParams;
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-md bg-white rounded shadow p-6">
<h1 className="text-xl font-semibold mb-1">Create your TOWER community</h1>
<p className="text-sm text-gray-500 mb-6">
Already have an account?{' '}
<a href="/login" className="text-blue-600 hover:underline">
Sign in
</a>
.
</p>
<SignupForm redirect={redirect} />
</div>
</div>
);
}
+20 -1
View File
@@ -9,11 +9,30 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-alert-dialog": "^1.1.17",
"@radix-ui/react-avatar": "^1.2.0",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-toast": "^1.2.17",
"@radix-ui/react-tooltip": "^1.2.10",
"@tanstack/react-query": "^5.101.0",
"@tower/types": "workspace:*", "@tower/types": "workspace:*",
"@tower/ui": "workspace:*", "@tower/ui": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-hook-form": "^7.80.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
File diff suppressed because one or more lines are too long

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