Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ patchats/
│ └── main/java/org/patinanetwork/patchats/
│ └── api/ # REST controllers + security config
├── js/ # TypeScript frontend (React + Vite)
│ ├── docs/frontend-structure.md # Frontend folder structure & conventions (READ THIS)
│ └── src/
│ ├── app/ # Pages, organized by route
│ ├── lib/ # Shared setup: router, theme, query provider
│ ├── app/ # App shell: router, guards, layouts, providers
│ ├── features/ # Domains — one folder per bounded context (pages + api + tests)
│ ├── components/ # App-shared UI primitives
│ ├── lib/ # App-shared infra: api client, query, test utils
│ └── main.tsx # App entry point
├── Justfile # All common dev commands
├── pom.xml # Maven config (backend deps + build)
Expand All @@ -25,23 +28,23 @@ patchats/

## Tech Stack

| Layer | Technology |
|---|---|
| Backend language | Java 25 + Spring Boot 3.x |
| Backend build | Maven (`./mvnw`) |
| Database | PostgreSQL + Flyway migrations |
| Data access | Spring JDBC (plain SQL — no ORM) |
| Auth | Spring Security + OAuth2 (no passwords stored) |
| API docs | SpringDoc / OpenAPI → `/v3/api-docs` |
| Frontend language | TypeScript (strict mode) |
| Frontend framework | React 18, functional components + hooks |
| Frontend build | Vite |
| UI components | Mantine 8 |
| Routing | React Router v6 — routes defined in `js/src/lib/router.tsx` |
| Forms + validation | Mantine Form + Zod (define Zod schema first, derive form from it) |
| Frontend pkg manager | pnpm (use `pnpm`, not `npm`) |
| Secrets | SOPS — never commit plaintext secrets |
| Task runner | `just` — run `just` to see all commands |
| Layer | Technology |
| -------------------- | ---------------------------------------------------------------------- |
| Backend language | Java 25 + Spring Boot 3.x |
| Backend build | Maven (`./mvnw`) |
| Database | PostgreSQL + Flyway migrations |
| Data access | Spring JDBC (plain SQL — no ORM) |
| Auth | Spring Security + OAuth2 (no passwords stored) |
| API docs | SpringDoc / OpenAPI → `/v3/api-docs` |
| Frontend language | TypeScript (strict mode) |
| Frontend framework | React 18, functional components + hooks |
| Frontend build | Vite |
| UI components | Mantine 8 |
| Routing | React Router v6 — central route tree in `js/src/app/router/router.tsx` |
| Forms + validation | Mantine Form + Zod (define Zod schema first, derive form from it) |
| Frontend pkg manager | pnpm (use `pnpm`, not `npm`) |
| Secrets | SOPS — never commit plaintext secrets |
| Task runner | `just` — run `just` to see all commands |

---

Expand All @@ -54,18 +57,22 @@ See `Justfile` at the repo root for all available commands.
## Code Conventions

**Backend**

- Controllers live in `src/main/java/.../api/` and are annotated with `@RestController`.
- All responses are wrapped in `ApiResponder<T>`.
- Every schema change requires a Flyway migration in `db/`.
- OpenAPI annotations (`@Operation`, `@Tag`) must be kept current.

**Frontend**
- Pages live under `js/src/app/` named `<Name>.page.tsx`.
- Components for a page go in a `_components/` sibling directory.
- Use Mantine components and `js/src/lib/theme.tsx` for all styling.
**Frontend** — see `js/docs/frontend-structure.md` for the full structure + conventions.

- Domain-first: feature code lives under `js/src/features/<domain>/`; pages are `<Name>.page.tsx`.
- Permissions live in the router (guards + layouts), not the folder tree.
- Data hooks are domain-owned (`features/<domain>/api/`); generated types + client live in `js/src/lib/api/`.
- Use Mantine components and `js/src/app/providers/theme.tsx` for styling.
- Imports use the `@/` alias for `js/src/`.

**Formatting / Linting (enforced in CI)**

- Backend: Spotless (Palantir Java Format) + Checkstyle (`checkstyle.xml`)
- Frontend: Prettier + ESLint + Stylelint

Expand All @@ -77,4 +84,4 @@ Secrets are encrypted with [SOPS](https://github.com/getsops/sops) and committed

- `.example.env` documents the environment variables the app expects.
- `.sops.yaml` defines the encryption keys and file patterns.
Local development env vars are loaded via `dotenvx` (see `Justfile` commands).
Local development env vars are loaded via `dotenvx` (see `Justfile` commands).
131 changes: 131 additions & 0 deletions js/docs/frontend-structure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Frontend structure & conventions

How the `js/` frontend (React 18 + Vite + TypeScript) is organized. Read this before adding
pages, features, or data hooks so new code lands in the right place.

Stack: React Router v6, TanStack Query, Mantine 8, Zod, MSW, `openapi-typescript` (types
generated from the backend's OpenAPI spec). Path alias `@/* → src/*`. ESLint enforces absolute
imports (`@`-prefixed) and import sorting; `/api` is proxied to the Spring backend.

## The shape

```
js/src/
main.tsx # entry
app/ # APP SHELL — bootstrapping, NOT pages
router/
router.tsx # central route tree: guards + layouts + page mounts
guards/
RequireAuth.tsx # redirect to /login if no session
RequireAdmin.tsx # redirect/403 if not admin
layouts/
PublicLayout.tsx # public chrome
AppLayout.tsx # authed chrome (sidebar, user menu)
AdminLayout.tsx # admin chrome
providers/
QueryProvider.tsx # TanStack Query provider
theme.tsx # Mantine theme
features/ # DOMAINS — one folder per bounded context
<domain>/ # e.g. pairings, profile, intake, admin
<Name>.page.tsx # a page (flat file until it grows)
<name>/ # page-FOLDER, only once the page gains local pieces
<Name>.page.tsx
components/ # used only by this page
components/ # domain-shared UI (2+ pages in this domain)
api/
useX.ts # query/mutation hooks (domain-owned)
schemas.ts # Zod (flat until it grows)
<domain>.mock.ts # MSW handlers (+fixtures) → mocks/ folder when it grows
hooks/ # non-data domain hooks (on demand)
types.ts # flat until it grows
components/ # APP-SHARED UI primitives (cross-domain)
lib/ # APP-SHARED, framework-agnostic infra
api/
schema.d.ts # openapi-typescript generated types (whole backend)
client.ts # typed fetch wrapper over /api
queryClient.ts # QueryClient instance/config
test/
defaults.tsx # vitest setupFile (wired in vite.config.ts)
server.ts # MSW setupServer composing domain *.mock.ts handlers
render.tsx # custom RTL render wrapping providers
utils/ # generic, domain-agnostic helpers
```

## Conventions

1. **Domain-first, not permission-first.** Folders are organized by domain. Permission is a
routing concern — never encode access (admin/public/authed) in the folder tree.
2. **Permissions live in the router.** Composable nested routes: a guard route (`RequireAuth`,
`RequireAdmin`) wraps a layout route wraps the page. A page's access level and visual frame
each change with a one-line route edit; the page file never moves.
3. **`features/` wrapper.** Everything under `features/` is a domain; everything else (`app/`,
`components/`, `lib/`) is shell or shared infra.
4. **Pages: hybrid granularity.** A page is a single `Name.page.tsx`. It graduates to a folder
(`name/Name.page.tsx` + a local `components/`) only once it accumulates page-local pieces.
Keep the `.page.tsx` suffix inside page-folders — don't use `index.tsx`.
5. **Data layer split.** Global (`lib/api/`): generated OpenAPI types + typed fetch client +
QueryClient. Domain-owned (`features/<d>/api/`): per-endpoint query/mutation hooks + Zod
schemas. `useSubmitIntake` belongs to `intake`, not a global pile.
6. **Concern folders, on demand.** A domain has `components/` + `api/`; add `hooks/` /
`utils.ts` / `types.ts` only as they grow (flat file first, folder at 2–3 files). There is
no domain-level `lib/` — `lib/` means app infra only.
7. **No barrels.** Import the specific file (`@/features/pairings/api/usePairings`). This keeps
Vite/HMR and tree-shaking fast and avoids circular-import bugs.
8. **Testing.** Tests colocate beside source (`Name.page.test.tsx`). Shared test infra lives in
`lib/test/`. MSW handlers are domain-owned, named `<domain>.mock.ts` in `features/<d>/api/`
(parallels `.test.ts`, and is excluded from the prod build); `lib/test/server.ts` composes
them. Promote to a `features/<d>/mocks/` folder when fixtures grow.
9. **Central router.** One `app/router/router.tsx` owns the full guard/layout/route tree and
imports page components from domains (thin wiring, not domain logic). It keeps the whole
access map visible in one place.
10. **Graduation rule.** Start at the narrowest scope; promote outward only on the _second real
consumer_: page-local → domain (`features/<d>/...`) → app-shared (`src/components`,
`src/lib`). Avoid premature promotion.
11. **Styles colocate.** `Foo.module.css` sits next to `Foo.tsx`.
12. **Cross-domain data access.** A domain's `api/` folder is its public surface — the one place
another domain may import from (never another domain's `components/` or page internals).
Prefer, in order: (a) **backend composition** — have the endpoint embed the fields you need
so no cross-call happens (avoids request waterfalls); (b) for genuine client-side cross-reads,
import the **canonical hook** from `features/<owner>/api` and reuse its query keys (one shared
cache entry); (c) keep dependencies **one-directional** — a cycle is the signal to extract a
shared piece. When a domain is consumed by **3+ domains** (e.g. the user/profile entity),
promote its _data layer_ to a shared `entities/<x>/` tier while its _pages_ stay in
`features/<x>/`.

## Where things go (quick reference)

| You're adding… | Put it in… |
| ------------------------------------ | ------------------------------------------------------------------------ |
| A new page | `features/<domain>/<Name>.page.tsx` (+ mount in `app/router/router.tsx`) |
| A query/mutation hook | `features/<domain>/api/useX.ts` |
| A Zod schema for an endpoint | `features/<domain>/api/schemas.ts` |
| UI used by 2+ pages in one domain | `features/<domain>/components/` |
| UI used across domains | `src/components/` |
| A request mock for tests | `features/<domain>/api/<domain>.mock.ts` |
| The typed fetch client / QueryClient | `src/lib/api/` |
| A new layout or route guard | `src/app/layouts/` or `src/app/router/guards/` |

## Decision log

The reasoning behind the conventions, for anyone tempted to reorganize.

1. **Domain vs permission as the primary axis → domain.** Permission is a routing concern; one
feature spans permission levels; permissions change (folders would churn); and a folder split
enforces nothing — real gating is the router guard + backend. Rejected top-level
`admin/ public/ authenticated/` dirs.
2. **Page granularity → hybrid (file until it grows).** Promotion is cheap because a page is
imported in exactly one place (the router). Rejected always-folder-per-page.
3. **Domain wrapper → `features/`.** Rejected flat-under-`src/` (domains blend with infra).
4. **Data layer → domain-owned hooks**, thin global (`lib/api/` = generated types + client +
QueryClient). Rejected a global `src/api/` pile.
5. **Domain insides → concern folders on demand.** Rejected a domain-level `lib/` (junk drawer;
name clash with app-infra `lib/`).
6. **Encapsulation → no barrels.** Rejected per-domain `index.ts` (Vite/tree-shaking cost,
circular-import risk). Enforce boundaries with lint instead, if needed.
7. **MSW handlers → domain-owned `<domain>.mock.ts`.** Rejected one global `handlers.ts`
(monolith that re-couples domains).
8. **Router → central `router.tsx`.** Rejected domain-contributed route objects for now
(fragments the access map); revisit only if the file becomes unwieldy.
9. **Cross-domain data → backend-composition first, else import the owner's `api/` hook.**
Rejected an upfront `entities/` tier (premature) and per-domain duplication (cache
fragmentation + double fetching). Promote to `entities/` only at 3+ consumers.
3 changes: 0 additions & 3 deletions js/src/app/Root.page.tsx

This file was deleted.

19 changes: 19 additions & 0 deletions js/src/app/layouts/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { AppShell, Badge, Group, Text } from "@mantine/core";
import { Outlet } from "react-router-dom";

/** Chrome for admin pages: the authed header plus an admin marker. */
export function AdminLayout() {
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" justify="space-between" px="md">
<Text fw={700}>PatChats</Text>
<Badge color="red">Admin</Badge>
</Group>
</AppShell.Header>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}
18 changes: 18 additions & 0 deletions js/src/app/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { AppShell, Group, Text } from "@mantine/core";
import { Outlet } from "react-router-dom";

/** Chrome for authenticated pages: a header plus the routed page content. */
export function AppLayout() {
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" justify="space-between" px="md">
<Text fw={700}>PatChats</Text>
</Group>
</AppShell.Header>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}
11 changes: 11 additions & 0 deletions js/src/app/layouts/PublicLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Container } from "@mantine/core";
import { Outlet } from "react-router-dom";

/** Chrome for public (unauthenticated) pages, e.g. landing and login. */
export function PublicLayout() {
return (
<Container py="xl" size="sm">
<Outlet />
</Container>
);
}
9 changes: 9 additions & 0 deletions js/src/app/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { queryClient } from "@/lib/api/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactNode } from "react";

export default function QueryProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
File renamed without changes.
17 changes: 17 additions & 0 deletions js/src/app/router/guards/RequireAdmin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useSession } from "@/lib/api/useSession";
import { Navigate, Outlet } from "react-router-dom";

/**
* Route guard: render the nested routes only for an admin. Assumes it is nested
* under RequireAuth, so the session is already resolved here; non-admins are sent
* home.
*/
export function RequireAdmin() {
const { data: session } = useSession();

if (!session?.isAdmin) {
return <Navigate replace to="/" />;
}

return <Outlet />;
}
26 changes: 26 additions & 0 deletions js/src/app/router/guards/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useSession } from "@/lib/api/useSession";
import { Center, Loader } from "@mantine/core";
import { Navigate, Outlet } from "react-router-dom";

/**
* Route guard: render the nested routes only for an authenticated user.
* While the session is loading, show a spinner; if there is no session, redirect
* to the public home (no /login page exists yet).
*/
export function RequireAuth() {
const { data: session, isPending } = useSession();

if (isPending) {
return (
<Center h="100vh">
<Loader />
</Center>
);
}

if (!session) {
return <Navigate replace to="/" />;
}

return <Outlet />;
}
37 changes: 37 additions & 0 deletions js/src/app/router/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { AdminLayout } from "@/app/layouts/AdminLayout";
import { AppLayout } from "@/app/layouts/AppLayout";
import { PublicLayout } from "@/app/layouts/PublicLayout";
import { RequireAdmin } from "@/app/router/guards/RequireAdmin";
import { RequireAuth } from "@/app/router/guards/RequireAuth";
import HomePage from "@/features/home/Home.page";
import SamplePage from "@/features/sample/Sample.page";
import SampleAdminPage from "@/features/sample/SampleAdmin.page";
import { createBrowserRouter } from "react-router-dom";

export const router = createBrowserRouter([
// Public: no guard, public chrome.
{
element: <PublicLayout />,
children: [{ index: true, element: <HomePage /> }],
},

// Authenticated: guard -> layout -> page. Admin nests a second guard + layout.
{
element: <RequireAuth />,
children: [
{
element: <AppLayout />,
children: [{ path: "sample", element: <SamplePage /> }],
},
{
element: <RequireAdmin />,
children: [
{
element: <AdminLayout />,
children: [{ path: "sample/admin", element: <SampleAdminPage /> }],
},
],
},
],
},
]);
3 changes: 0 additions & 3 deletions js/src/app/user/admin/Admin.page.tsx

This file was deleted.

Empty file.
3 changes: 0 additions & 3 deletions js/src/app/user/admin/emails/Emails.page.tsx

This file was deleted.

Empty file.
3 changes: 0 additions & 3 deletions js/src/app/user/intake/IntakeForm.page.tsx

This file was deleted.

Empty file.
17 changes: 17 additions & 0 deletions js/src/features/home/Home.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Anchor, Stack, Text, Title } from "@mantine/core";
import { Link } from "react-router-dom";

/** Public landing page. */
export default function HomePage() {
return (
<Stack>
<Title order={1}>PatChats</Title>
<Text>
Monthly one-on-one coffee chat pairings for the Patina Network.
</Text>
<Anchor component={Link} to="/sample">
Go to the app
</Anchor>
</Stack>
);
}
Loading