diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..df19817 --- /dev/null +++ b/CLAUDE.md @@ -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('/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; + +function MyForm() { + const form = useForm({ resolver: zodResolver(schema) }); + return ( +
+ + ( + + Email + + + + )} /> + + + ); +} +``` + +### 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: (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() as T : Promise.reject(r)), + post: (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: (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 {children}; +} +``` + +### 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. diff --git a/apps/web/app/_components/portal-login-shell.tsx b/apps/web/app/_components/portal-login-shell.tsx new file mode 100644 index 0000000..c300647 --- /dev/null +++ b/apps/web/app/_components/portal-login-shell.tsx @@ -0,0 +1,49 @@ +import { cn } from '../_lib/utils'; + +interface PortalLoginShellProps { + title: string; + subtitle: string; + accentClass?: string; + children: React.ReactNode; + footer?: React.ReactNode; +} + +export function PortalLoginShell({ title, subtitle, accentClass = 'bg-primary', children, footer }: PortalLoginShellProps) { + return ( +
+ {/* Left branding panel */} +
+
+ TOWER +
+
+
+

+ "Community knowledge, beautifully organised." +

+
Insignia TOWER Platform
+
+
+
+ + {/* Right form panel */} +
+
+ {/* Mobile logo */} +
+ TOWER +
+ +
+

{title}

+

{subtitle}

+
+ + {children} + + {footer &&
{footer}
} +
+
+
+ ); +} diff --git a/apps/web/app/_components/portal-nav.tsx b/apps/web/app/_components/portal-nav.tsx new file mode 100644 index 0000000..5de1ba3 --- /dev/null +++ b/apps/web/app/_components/portal-nav.tsx @@ -0,0 +1,82 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { cn } from '../_lib/utils'; +import { Button } from './ui/button'; +import { Separator } from './ui/separator'; +import { LogOut } from 'lucide-react'; + +interface NavItem { + href: string; + label: string; + icon?: React.ReactNode; +} + +interface PortalNavProps { + portalName: string; + navItems: NavItem[]; + user?: { name?: string | null; email?: string | null; role?: string }; + onLogout: () => void; + accentClass?: string; +} + +export function PortalNav({ portalName, navItems, user, onLogout, accentClass = 'text-primary' }: PortalNavProps) { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/apps/web/app/_components/ui/badge.tsx b/apps/web/app/_components/ui/badge.tsx new file mode 100644 index 0000000..d6dce23 --- /dev/null +++ b/apps/web/app/_components/ui/badge.tsx @@ -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, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/apps/web/app/_components/ui/button.tsx b/apps/web/app/_components/ui/button.tsx new file mode 100644 index 0000000..35812b1 --- /dev/null +++ b/apps/web/app/_components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ; + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/apps/web/app/_components/ui/card.tsx b/apps/web/app/_components/ui/card.tsx new file mode 100644 index 0000000..5c01865 --- /dev/null +++ b/apps/web/app/_components/ui/card.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { cn } from '../../_lib/utils'; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>

, +); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/web/app/_components/ui/form.tsx b/apps/web/app/_components/ui/form.tsx new file mode 100644 index 0000000..699bc1d --- /dev/null +++ b/apps/web/app/_components/ui/form.tsx @@ -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 = FieldPath, +> = { name: TName }; + +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => ( + + + +); + +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 '); + 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({} as FormItemContextValue); + +const FormItem = React.forwardRef>(({ className, ...props }, ref) => { + const id = React.useId(); + return ( + +
+ + ); +}); +FormItem.displayName = 'FormItem'; + +const FormLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + return