From 8eba5b2d9e8db42d61addf1becd3093f62939da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 24 May 2026 10:44:33 +0200 Subject: [PATCH] fix(journal): centralize session-auth helpers + extract .server.ts siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #406 — addresses the two items deferred from the audit: #7 — Centralize auth helpers - New `requireSessionUser(request)` in lib/auth/session.server.ts that returns the user or throws a redirect to /auth/login. - New `requireSessionUserJson(request)` companion that throws a 401 JSON response (for fetcher/JSON endpoints). - Replace the repeated const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); pattern across 18 route loaders/actions. Removes the duplicated guard preamble and gives a single chokepoint to evolve later (e.g., for terms-version gating). #8 — Extract heavy loaders into .server.ts siblings - routes/home.tsx → home.server.ts (DB count query + listActivities + listRecentPublicActivities) - routes/users.$username.tsx → users.$username.server.ts (user lookup + follow state + counts + listPublicRoutes/Activities + persona check) - routes/settings.connections.tsx → settings.connections.server.ts (connected_services join + manifest merge) Each route file shrinks to a thin delegator: `loader` calls `loadXxx(request)`. The component module no longer transitively pulls `getDb` and Drizzle schema into its import graph — Vite's tree-shake already strips server-only code from the client bundle, but the explicit `.server.ts` suffix makes that contract local and auditable. Other 17 routes that mix loader/action with components are left as-is for now: they're each small enough that the split adds churn without buying much clarity. The pattern is documented by the three examples; the rest can convert opportunistically when they grow. Tests: - lib/auth/session.server.test.ts (4 cases — redirect for missing cookie, redirect for ghost userId, success path, JSON 401 variant) Full repo: pnpm typecheck, pnpm lint, pnpm test all green (181 passed | 31 integration-gated skipped). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/lib/auth/session.server.test.ts | 85 ++++++++++++++++ apps/journal/app/lib/auth/session.server.ts | 30 +++++- apps/journal/app/routes/activities.$id.tsx | 5 +- apps/journal/app/routes/activities._index.tsx | 7 +- apps/journal/app/routes/activities.new.tsx | 8 +- .../app/routes/api.sync.callback.$provider.ts | 5 +- .../app/routes/api.sync.connect.$provider.ts | 5 +- .../routes/api.sync.disconnect.$provider.ts | 5 +- .../app/routes/api.sync.komoot.connect.ts | 7 +- .../routes/api.sync.komoot.import-status.ts | 7 +- .../app/routes/api.sync.komoot.import.ts | 7 +- .../app/routes/api.sync.komoot.verify.ts | 7 +- .../api.sync.push.$provider.$routeId.ts | 5 +- apps/journal/app/routes/auth.verify.tsx | 5 +- apps/journal/app/routes/home.server.ts | 92 ++++++++++++++++++ apps/journal/app/routes/home.tsx | 82 +--------------- apps/journal/app/routes/routes.$id.edit.tsx | 8 +- apps/journal/app/routes/routes.$id.tsx | 5 +- apps/journal/app/routes/routes._index.tsx | 7 +- apps/journal/app/routes/routes.new.tsx | 8 +- .../app/routes/settings.connections.server.ts | 36 +++++++ .../app/routes/settings.connections.tsx | 33 +------ .../app/routes/sync.import.$provider.tsx | 8 +- .../journal/app/routes/sync.import.komoot.tsx | 8 +- .../app/routes/users.$username.server.ts | 97 +++++++++++++++++++ apps/journal/app/routes/users.$username.tsx | 91 +---------------- 26 files changed, 393 insertions(+), 270 deletions(-) create mode 100644 apps/journal/app/lib/auth/session.server.test.ts create mode 100644 apps/journal/app/routes/home.server.ts create mode 100644 apps/journal/app/routes/settings.connections.server.ts create mode 100644 apps/journal/app/routes/users.$username.server.ts diff --git a/apps/journal/app/lib/auth/session.server.test.ts b/apps/journal/app/lib/auth/session.server.test.ts new file mode 100644 index 0000000..ff387a9 --- /dev/null +++ b/apps/journal/app/lib/auth/session.server.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getDb: vi.fn(), +})); + +vi.mock("../db.ts", () => ({ getDb: mocks.getDb })); + +import { + requireSessionUser, + requireSessionUserJson, + sessionStorage, +} from "./session.server.ts"; + +async function requestWithSession(userId: string | null): Promise { + const session = await sessionStorage.getSession(); + if (userId) session.set("userId", userId); + const cookie = await sessionStorage.commitSession(session); + return new Request("http://test.local/", { + headers: { cookie }, + }); +} + +describe("requireSessionUser", () => { + beforeEach(() => { + mocks.getDb.mockReset(); + }); + + it("throws a redirect to /auth/login when no session cookie", async () => { + const req = new Request("http://test.local/"); + try { + await requireSessionUser(req); + throw new Error("should have thrown"); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + const resp = thrown as Response; + expect(resp.status).toBe(302); + expect(resp.headers.get("location")).toBe("/auth/login"); + } + }); + + it("throws a redirect when session userId points at a missing user", async () => { + mocks.getDb.mockReturnValue({ + select: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }), + }); + const req = await requestWithSession("ghost-user"); + try { + await requireSessionUser(req); + throw new Error("should have thrown"); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + expect((thrown as Response).headers.get("location")).toBe("/auth/login"); + } + }); + + it("returns the user when the session is valid", async () => { + const user = { id: "u1", username: "alice" }; + mocks.getDb.mockReturnValue({ + select: () => ({ from: () => ({ where: () => Promise.resolve([user]) }) }), + }); + const req = await requestWithSession("u1"); + const result = await requireSessionUser(req); + expect(result).toEqual(user); + }); +}); + +describe("requireSessionUserJson", () => { + beforeEach(() => { + mocks.getDb.mockReset(); + }); + + it("throws a 401 JSON Response when unauthenticated", async () => { + const req = new Request("http://test.local/"); + try { + await requireSessionUserJson(req); + throw new Error("should have thrown"); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + const resp = thrown as Response; + expect(resp.status).toBe(401); + const body = (await resp.json()) as { error: string }; + expect(body.error).toBe("Unauthorized"); + } + }); +}); diff --git a/apps/journal/app/lib/auth/session.server.ts b/apps/journal/app/lib/auth/session.server.ts index c2d20b3..2230bee 100644 --- a/apps/journal/app/lib/auth/session.server.ts +++ b/apps/journal/app/lib/auth/session.server.ts @@ -5,7 +5,7 @@ // The legacy import path `~/lib/auth.server` continues to re-export // these symbols for backwards compat — see auth.server.ts. -import { createCookieSessionStorage } from "react-router"; +import { createCookieSessionStorage, redirect } from "react-router"; import { eq } from "drizzle-orm"; import { users } from "@trails-cool/db/schema/journal"; import { getDb } from "../db.ts"; @@ -40,6 +40,34 @@ export async function getSessionUser(request: Request) { return user ?? null; } +/** + * Loader/action helper: return the session user or throw a redirect to + * /auth/login. Centralizes the repeated + * const user = await getSessionUser(request); + * if (!user) return redirect("/auth/login"); + * pattern across page loaders and form actions. + */ +export async function requireSessionUser(request: Request) { + const user = await getSessionUser(request); + if (!user) { + throw redirect("/auth/login"); + } + return user; +} + +/** + * Same as requireSessionUser but throws a 401 JSON response instead of a + * redirect. For fetcher/JSON endpoints (`/api/*` non-v1) where redirecting + * would confuse the client-side caller. + */ +export async function requireSessionUserJson(request: Request) { + const user = await getSessionUser(request); + if (!user) { + throw Response.json({ error: "Unauthorized" }, { status: 401 }); + } + return user; +} + export async function destroySession(request: Request) { const session = await sessionStorage.getSession(request.headers.get("Cookie")); return sessionStorage.destroySession(session); diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 7b4da0d..715f29e 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -2,7 +2,7 @@ import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities.$id"; import { canView } from "~/lib/auth.server"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { getSessionUser, requireSessionUser } from "~/lib/auth/session.server"; import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity, updateActivityVisibility } from "~/lib/activities.server"; import { deleteImportByActivity } from "~/lib/sync/imports.server"; import { listRoutes } from "~/lib/routes.server"; @@ -50,8 +50,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { } export async function action({ params, request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const formData = await request.formData(); const intent = formData.get("intent"); diff --git a/apps/journal/app/routes/activities._index.tsx b/apps/journal/app/routes/activities._index.tsx index 0cb19e4..4056c73 100644 --- a/apps/journal/app/routes/activities._index.tsx +++ b/apps/journal/app/routes/activities._index.tsx @@ -1,14 +1,13 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities._index"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { listActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const url = new URL(request.url); const sortParam = url.searchParams.get("sort"); diff --git a/apps/journal/app/routes/activities.new.tsx b/apps/journal/app/routes/activities.new.tsx index 5de4291..fc55122 100644 --- a/apps/journal/app/routes/activities.new.tsx +++ b/apps/journal/app/routes/activities.new.tsx @@ -1,12 +1,11 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/activities.new"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { createActivity } from "~/lib/activities.server"; import { listRoutes } from "~/lib/routes.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const userRoutes = await listRoutes(user.id); return data({ @@ -15,8 +14,7 @@ export async function loader({ request }: Route.LoaderArgs) { } export async function action({ request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const formData = await request.formData(); const name = formData.get("name") as string; diff --git a/apps/journal/app/routes/api.sync.callback.$provider.ts b/apps/journal/app/routes/api.sync.callback.$provider.ts index 15a04e1..800db0c 100644 --- a/apps/journal/app/routes/api.sync.callback.$provider.ts +++ b/apps/journal/app/routes/api.sync.callback.$provider.ts @@ -1,7 +1,7 @@ import { redirect, data } from "react-router"; import { getOrigin } from "~/lib/config.server"; import type { Route } from "./+types/api.sync.callback.$provider"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest, link } from "~/lib/connected-services"; import { decodeOAuthState, @@ -9,8 +9,7 @@ import { import { pushRouteToProvider } from "~/lib/connected-services/push-action.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const manifest = getManifest(params.provider); if (!manifest || !manifest.exchangeCode) { diff --git a/apps/journal/app/routes/api.sync.connect.$provider.ts b/apps/journal/app/routes/api.sync.connect.$provider.ts index be32e3c..cfbc2ca 100644 --- a/apps/journal/app/routes/api.sync.connect.$provider.ts +++ b/apps/journal/app/routes/api.sync.connect.$provider.ts @@ -1,13 +1,12 @@ import { redirect, data } from "react-router"; import { getOrigin } from "~/lib/config.server"; import type { Route } from "./+types/api.sync.connect.$provider"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest } from "~/lib/connected-services"; import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + await requireSessionUser(request); const manifest = getManifest(params.provider); if (!manifest || !manifest.buildAuthUrl) { diff --git a/apps/journal/app/routes/api.sync.disconnect.$provider.ts b/apps/journal/app/routes/api.sync.disconnect.$provider.ts index 6bc31a1..017134b 100644 --- a/apps/journal/app/routes/api.sync.disconnect.$provider.ts +++ b/apps/journal/app/routes/api.sync.disconnect.$provider.ts @@ -1,11 +1,10 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/api.sync.disconnect.$provider"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest, unlinkByUserProvider } from "~/lib/connected-services"; export async function action({ params, request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const manifest = getManifest(params.provider); if (!manifest) return data({ error: "Unknown provider" }, { status: 404 }); diff --git a/apps/journal/app/routes/api.sync.komoot.connect.ts b/apps/journal/app/routes/api.sync.komoot.connect.ts index 55c25ee..c25387d 100644 --- a/apps/journal/app/routes/api.sync.komoot.connect.ts +++ b/apps/journal/app/routes/api.sync.komoot.connect.ts @@ -1,16 +1,15 @@ // POST /api/sync/komoot/connect // Validates Komoot email/password credentials and stores them encrypted. -import { data, redirect } from "react-router"; +import { data } from "react-router"; import type { Route } from "./+types/api.sync.komoot.connect"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { loginKomoot } from "~/lib/komoot.server"; import { encrypt } from "~/lib/crypto.server"; import { link } from "~/lib/connected-services/manager"; export async function action({ request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const body = (await request.json()) as { email?: string; password?: string }; const email = body.email?.trim() ?? ""; diff --git a/apps/journal/app/routes/api.sync.komoot.import-status.ts b/apps/journal/app/routes/api.sync.komoot.import-status.ts index c0a507f..0f1da56 100644 --- a/apps/journal/app/routes/api.sync.komoot.import-status.ts +++ b/apps/journal/app/routes/api.sync.komoot.import-status.ts @@ -1,17 +1,16 @@ // GET /api/sync/komoot/import-status // Returns the most recent import batch for the authenticated user's Komoot connection. -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { desc, eq, and } from "drizzle-orm"; import type { Route } from "./+types/api.sync.komoot.import-status"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getService } from "~/lib/connected-services/manager"; import { getDb } from "~/lib/db"; import { importBatches } from "@trails-cool/db/schema/journal"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const service = await getService(user.id, "komoot"); if (!service) return data({ batch: null }); diff --git a/apps/journal/app/routes/api.sync.komoot.import.ts b/apps/journal/app/routes/api.sync.komoot.import.ts index d0f0267..4c84a95 100644 --- a/apps/journal/app/routes/api.sync.komoot.import.ts +++ b/apps/journal/app/routes/api.sync.komoot.import.ts @@ -3,17 +3,16 @@ // Returns { batchId } immediately; poll /api/sync/komoot/import-status for progress. import { randomUUID } from "node:crypto"; -import { data, redirect } from "react-router"; +import { data } from "react-router"; import type { Route } from "./+types/api.sync.komoot.import"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getService } from "~/lib/connected-services/manager"; import { getBoss } from "~/lib/boss.server"; import { getDb } from "~/lib/db"; import { importBatches } from "@trails-cool/db/schema/journal"; export async function action({ request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const service = await getService(user.id, "komoot"); if (!service) return data({ error: "not_connected" }, { status: 400 }); diff --git a/apps/journal/app/routes/api.sync.komoot.verify.ts b/apps/journal/app/routes/api.sync.komoot.verify.ts index eeb64a3..e62e7ab 100644 --- a/apps/journal/app/routes/api.sync.komoot.verify.ts +++ b/apps/journal/app/routes/api.sync.komoot.verify.ts @@ -3,15 +3,14 @@ // trails.cool profile URL appears in their Komoot bio. // On success, creates or replaces the connected service row in public mode. -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { getOrigin } from "~/lib/config.server"; import type { Route } from "./+types/api.sync.komoot.verify"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { parseKomootUserId, verifyKomootOwnership } from "~/lib/komoot.server"; import { link } from "~/lib/connected-services/manager"; export async function action({ request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const body = (await request.json()) as { komootProfileUrl?: string }; const input = body.komootProfileUrl?.trim() ?? ""; diff --git a/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts b/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts index 015e278..bb4d7e3 100644 --- a/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts +++ b/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts @@ -1,14 +1,13 @@ import { redirect, data } from "react-router"; import { getOrigin } from "~/lib/config.server"; import type { Route } from "./+types/api.sync.push.$provider.$routeId"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest } from "~/lib/connected-services"; import { pushRouteToProvider } from "~/lib/connected-services/push-action.server"; import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server"; export async function action({ params, request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const manifest = getManifest(params.provider); if (!manifest) return data({ error: "Unknown provider" }, { status: 404 }); diff --git a/apps/journal/app/routes/auth.verify.tsx b/apps/journal/app/routes/auth.verify.tsx index 12fc00b..c122a5a 100644 --- a/apps/journal/app/routes/auth.verify.tsx +++ b/apps/journal/app/routes/auth.verify.tsx @@ -1,7 +1,7 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/auth.verify"; import { verifyMagicToken, verifyEmailChange } from "~/lib/auth.server"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { completeAuth } from "~/lib/auth/completion.server"; export async function loader({ request }: Route.LoaderArgs) { @@ -15,8 +15,7 @@ export async function loader({ request }: Route.LoaderArgs) { try { if (isEmailChange) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); await verifyEmailChange(token, user.id); return redirect("/settings/account"); } diff --git a/apps/journal/app/routes/home.server.ts b/apps/journal/app/routes/home.server.ts new file mode 100644 index 0000000..e099e9d --- /dev/null +++ b/apps/journal/app/routes/home.server.ts @@ -0,0 +1,92 @@ +// Server-only data loader for the home route. Lives separately from +// `home.tsx` so the component file imports nothing that pulls in the DB +// client or other server-side modules at module-evaluation time. The +// route's `loader` is a thin delegator. + +import { eq, count } from "drizzle-orm"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { getDb } from "~/lib/db"; +import { credentials } from "@trails-cool/db/schema/journal"; +import { listActivities, listRecentPublicActivities } from "~/lib/activities.server"; + +export interface HomeActivityCard { + id: string; + name: string; + distance: number | null; + elevationGain: number | null; + duration: number | null; + startedAt: string | null; + createdAt: string; + geojson: string | null; + // Populated only for the public (logged-out) feed, where the card + // needs to attribute the activity to an owner. Personal feed skips + // these because it's always "you". + ownerUsername: string | null; + ownerDisplayName: string | null; +} + +export interface HomeLoaderData { + user: { id: string; username: string; displayName: string | null } | null; + showAddPasskey: boolean; + plannerUrl: string; + isFlagship: boolean; + activities: HomeActivityCard[]; +} + +export async function loadHomeData(request: Request): Promise { + const user = await getSessionUser(request); + const url = new URL(request.url); + const addPasskeyParam = url.searchParams.get("add-passkey") === "1" && user !== null; + + let showAddPasskey = false; + if (addPasskeyParam && user) { + const db = getDb(); + const [row] = await db + .select({ count: count() }) + .from(credentials) + .where(eq(credentials.userId, user.id)); + showAddPasskey = (row?.count ?? 0) === 0; + } + + const plannerUrl = process.env.PLANNER_URL ?? "https://planner.trails.cool"; + const isFlagship = process.env.IS_FLAGSHIP === "true"; + + let activities: HomeActivityCard[]; + if (user) { + const rows = await listActivities(user.id); + activities = rows.slice(0, 20).map((a) => ({ + id: a.id, + name: a.name, + distance: a.distance, + elevationGain: a.elevationGain, + duration: a.duration, + startedAt: a.startedAt?.toISOString() ?? null, + createdAt: a.createdAt.toISOString(), + geojson: a.geojson ?? null, + ownerUsername: null, + ownerDisplayName: null, + })); + } else { + const rows = await listRecentPublicActivities(20); + activities = rows.map((a) => ({ + id: a.id, + name: a.name, + distance: a.distance, + elevationGain: a.elevationGain, + duration: a.duration, + startedAt: a.startedAt?.toISOString() ?? null, + createdAt: a.createdAt.toISOString(), + geojson: a.geojson ?? null, + ownerUsername: a.ownerUsername, + ownerDisplayName: a.ownerDisplayName, + })); + } + + return { + user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null, + showAddPasskey, + plannerUrl, + isFlagship, + activities, + }; +} diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index 981c2ac..2e3f580 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -1,14 +1,10 @@ import { useState, useCallback, useEffect } from "react"; import { data } from "react-router"; import { useTranslation } from "react-i18next"; -import { eq, count } from "drizzle-orm"; import type { Route } from "./+types/home"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { getDb } from "~/lib/db"; -import { credentials } from "@trails-cool/db/schema/journal"; -import { listActivities, listRecentPublicActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; +import { loadHomeData } from "./home.server"; export function meta(_args: Route.MetaArgs) { return [ @@ -17,82 +13,8 @@ export function meta(_args: Route.MetaArgs) { ]; } -interface ActivityCard { - id: string; - name: string; - distance: number | null; - elevationGain: number | null; - duration: number | null; - startedAt: string | null; - createdAt: string; - geojson: string | null; - // Populated only for the public (logged-out) feed, where the card - // needs to attribute the activity to an owner. Personal feed skips - // these because it's always "you". - ownerUsername: string | null; - ownerDisplayName: string | null; -} - export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - const url = new URL(request.url); - const addPasskeyParam = url.searchParams.get("add-passkey") === "1" && user !== null; - - // Only show the add-passkey prompt if the user has no passkeys yet - let showAddPasskey = false; - if (addPasskeyParam && user) { - const db = getDb(); - const [row] = await db - .select({ count: count() }) - .from(credentials) - .where(eq(credentials.userId, user.id)); - showAddPasskey = (row?.count ?? 0) === 0; - } - - const plannerUrl = process.env.PLANNER_URL ?? "https://planner.trails.cool"; - const isFlagship = process.env.IS_FLAGSHIP === "true"; - - // Logged-in users get their own recent activities as the home feed — - // "home" should mean your stuff, not the instance's public stream. - // Logged-out visitors get the instance-wide public feed instead. - let activities: ActivityCard[]; - if (user) { - const rows = await listActivities(user.id); - activities = rows.slice(0, 20).map((a) => ({ - id: a.id, - name: a.name, - distance: a.distance, - elevationGain: a.elevationGain, - duration: a.duration, - startedAt: a.startedAt?.toISOString() ?? null, - createdAt: a.createdAt.toISOString(), - geojson: a.geojson ?? null, - ownerUsername: null, - ownerDisplayName: null, - })); - } else { - const rows = await listRecentPublicActivities(20); - activities = rows.map((a) => ({ - id: a.id, - name: a.name, - distance: a.distance, - elevationGain: a.elevationGain, - duration: a.duration, - startedAt: a.startedAt?.toISOString() ?? null, - createdAt: a.createdAt.toISOString(), - geojson: a.geojson ?? null, - ownerUsername: a.ownerUsername, - ownerDisplayName: a.ownerDisplayName, - })); - } - - return data({ - user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null, - showAddPasskey, - plannerUrl, - isFlagship, - activities, - }); + return data(await loadHomeData(request)); } export default function Home({ loaderData }: Route.ComponentProps) { diff --git a/apps/journal/app/routes/routes.$id.edit.tsx b/apps/journal/app/routes/routes.$id.edit.tsx index 59497fb..1d50d92 100644 --- a/apps/journal/app/routes/routes.$id.edit.tsx +++ b/apps/journal/app/routes/routes.$id.edit.tsx @@ -1,15 +1,14 @@ import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id.edit"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getRoute, updateRoute } from "~/lib/routes.server"; import type { Visibility } from "@trails-cool/db/schema/journal"; const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); export async function loader({ params, request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const route = await getRoute(params.id); if (!route) throw data({ error: "Route not found" }, { status: 404 }); @@ -26,8 +25,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { } export async function action({ params, request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const formData = await request.formData(); const name = formData.get("name") as string; diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 27dc611..c7df64f 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id"; import { and, eq } from "drizzle-orm"; import { canView } from "~/lib/auth.server"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { getSessionUser, requireSessionUser } from "~/lib/auth/session.server"; import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; import { getDb } from "~/lib/db"; import { syncPushes } from "@trails-cool/db/schema/journal"; @@ -142,8 +142,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { } export async function action({ params, request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const formData = await request.formData(); const intent = formData.get("intent"); diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index a4fed7a..fe3938a 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -1,14 +1,13 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes._index"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const userRoutes = await listRoutes(user.id); return data({ diff --git a/apps/journal/app/routes/routes.new.tsx b/apps/journal/app/routes/routes.new.tsx index caf02b2..c89f7e3 100644 --- a/apps/journal/app/routes/routes.new.tsx +++ b/apps/journal/app/routes/routes.new.tsx @@ -1,18 +1,16 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/routes.new"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { createRoute } from "~/lib/routes.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + await requireSessionUser(request); return data({}); } export async function action({ request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const formData = await request.formData(); const name = formData.get("name") as string; diff --git a/apps/journal/app/routes/settings.connections.server.ts b/apps/journal/app/routes/settings.connections.server.ts new file mode 100644 index 0000000..41b14f9 --- /dev/null +++ b/apps/journal/app/routes/settings.connections.server.ts @@ -0,0 +1,36 @@ +// Server-only loader for /settings/connections. Pulled out of the route +// file so the component module doesn't pull `getDb` + Drizzle schema +// into its module graph (only the loader does, via `import("...")` at +// load time). + +import { eq } from "drizzle-orm"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { getDb } from "~/lib/db"; +import { connectedServices } from "@trails-cool/db/schema/journal"; +import { getAllManifests } from "~/lib/connected-services"; + +export async function loadConnectionsSettings(request: Request) { + const user = await requireSessionUser(request); + + const db = getDb(); + const connections = await db + .select({ + provider: connectedServices.provider, + providerUserId: connectedServices.providerUserId, + }) + .from(connectedServices) + .where(eq(connectedServices.userId, user.id)); + + const providers = getAllManifests().map((m) => { + const conn = connections.find((c) => c.provider === m.id); + return { + id: m.id, + name: m.displayName, + connected: !!conn, + providerUserId: conn?.providerUserId, + connectUrl: m.connectUrl ?? null, + }; + }); + + return { providers }; +} diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx index e0c85af..68ada37 100644 --- a/apps/journal/app/routes/settings.connections.tsx +++ b/apps/journal/app/routes/settings.connections.tsx @@ -1,11 +1,7 @@ -import { data, redirect, useSearchParams } from "react-router"; +import { data, useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; -import { eq } from "drizzle-orm"; import type { Route } from "./+types/settings.connections"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { getDb } from "~/lib/db"; -import { connectedServices } from "@trails-cool/db/schema/journal"; -import { getAllManifests } from "~/lib/connected-services"; +import { loadConnectionsSettings } from "./settings.connections.server"; const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const; type KnownError = (typeof KNOWN_ERRORS)[number]; @@ -18,30 +14,7 @@ export function meta() { } export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - - const db = getDb(); - const connections = await db - .select({ - provider: connectedServices.provider, - providerUserId: connectedServices.providerUserId, - }) - .from(connectedServices) - .where(eq(connectedServices.userId, user.id)); - - const providers = getAllManifests().map((m) => { - const conn = connections.find((c) => c.provider === m.id); - return { - id: m.id, - name: m.displayName, - connected: !!conn, - providerUserId: conn?.providerUserId, - connectUrl: m.connectUrl ?? null, - }; - }); - - return data({ providers }); + return data(await loadConnectionsSettings(request)); } export default function ConnectionsSettings({ loaderData }: Route.ComponentProps) { diff --git a/apps/journal/app/routes/sync.import.$provider.tsx b/apps/journal/app/routes/sync.import.$provider.tsx index fce797a..99bd41f 100644 --- a/apps/journal/app/routes/sync.import.$provider.tsx +++ b/apps/journal/app/routes/sync.import.$provider.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/sync.import.$provider"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest, getService, @@ -13,8 +13,7 @@ import { createActivity } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; export async function loader({ params, request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const manifest = getManifest(params.provider); if (!manifest || !manifest.importer) { @@ -48,8 +47,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { } export async function action({ params, request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const manifest = getManifest(params.provider); if (!manifest || !manifest.importer) { diff --git a/apps/journal/app/routes/sync.import.komoot.tsx b/apps/journal/app/routes/sync.import.komoot.tsx index 07ab181..e74efbe 100644 --- a/apps/journal/app/routes/sync.import.komoot.tsx +++ b/apps/journal/app/routes/sync.import.komoot.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef } from "react"; import { data, redirect, useFetcher, useRevalidator } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/sync.import.komoot"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; import { getService } from "~/lib/connected-services"; import { getDb } from "~/lib/db"; import { importBatches } from "@trails-cool/db/schema/journal"; @@ -16,8 +16,7 @@ export function meta() { } export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + const user = await requireSessionUser(request); const service = await getService(user.id, "komoot"); if (!service) return redirect("/settings/connections/komoot"); @@ -47,8 +46,7 @@ export async function loader({ request }: Route.LoaderArgs) { } export async function action({ request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) return redirect("/auth/login"); + await requireSessionUser(request); // Delegate to the API route — just redirect so the page reloads with // the new batch after the POST. diff --git a/apps/journal/app/routes/users.$username.server.ts b/apps/journal/app/routes/users.$username.server.ts new file mode 100644 index 0000000..280f9cd --- /dev/null +++ b/apps/journal/app/routes/users.$username.server.ts @@ -0,0 +1,97 @@ +// Server-only loader for the user profile page. Splitting this out keeps +// the route component file free of direct DB/auth/follow imports — see +// `home.server.ts` for the pattern. + +import { data } from "react-router"; +import { eq } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { users } from "@trails-cool/db/schema/journal"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { listPublicRoutesForOwner } from "~/lib/routes.server"; +import { listPublicActivitiesForOwner } from "~/lib/activities.server"; +import { loadPersona } from "~/lib/demo-bot.server"; +import { countFollowers, countFollowing, getFollowState } from "~/lib/follow.server"; + +export async function loadUserProfile(request: Request, username: string) { + const db = getDb(); + const [user] = await db.select().from(users).where(eq(users.username, username)); + + if (!user) { + throw data({ error: "User not found" }, { status: 404 }); + } + + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + + // Follow state: null when anonymous or owner; { following, pending } + // otherwise. + const followState = !isOwn && currentUser + ? await getFollowState(currentUser.id, user.username) + : null; + + // Locked-account model: a private profile renders a stub for + // non-followers (anonymous OR signed-in but not an accepted follower). + // Owners always see their own profile in full. + const canSeeContent = + isOwn || + user.profileVisibility === "public" || + (followState !== null && followState.following === true); + + // For private-stub viewers we still want counts (cheap) but skip the + // expensive content fetches. + const [followers, following] = await Promise.all([ + countFollowers(user.id), + countFollowing(user.id), + ]); + const url = new URL(request.url); + const sortParam = url.searchParams.get("sort"); + const activitySort = sortParam === "addedAt" ? "addedAt" : "startedAt"; + + const [publicRoutes, publicActivities] = canSeeContent + ? await Promise.all([ + listPublicRoutesForOwner(user.id), + listPublicActivitiesForOwner(user.id, activitySort), + ]) + : [[], []]; + + // Demo-account badge: true when this profile matches the instance's + // configured demo persona username. Computed server-side so we don't + // ship the persona config through client HTML. + const isDemoUser = user.username === loadPersona().username; + + return { + user: { + username: user.username, + displayName: user.displayName, + bio: user.bio, + domain: user.domain, + createdAt: user.createdAt.toISOString(), + }, + routes: publicRoutes.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + distance: r.distance, + elevationGain: r.elevationGain, + updatedAt: r.updatedAt.toISOString(), + })), + activities: publicActivities.map((a) => ({ + id: a.id, + name: a.name, + description: a.description, + distance: a.distance, + duration: a.duration, + startedAt: a.startedAt?.toISOString() ?? null, + createdAt: a.createdAt.toISOString(), + })), + activitySort, + isOwn, + isDemoUser, + followers, + following, + followState, + isLoggedIn: currentUser !== null, + profileVisibility: user.profileVisibility, + canSeeContent, + }; +} diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index 53bb470..3b22319 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -1,99 +1,12 @@ import { data } from "react-router"; import type { Route } from "./+types/users.$username"; import { useTranslation } from "react-i18next"; -import { getDb } from "~/lib/db"; -import { users } from "@trails-cool/db/schema/journal"; -import { eq } from "drizzle-orm"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { listPublicRoutesForOwner } from "~/lib/routes.server"; -import { listPublicActivitiesForOwner } from "~/lib/activities.server"; -import { loadPersona } from "~/lib/demo-bot.server"; -import { countFollowers, countFollowing, getFollowState } from "~/lib/follow.server"; import { ClientDate } from "~/components/ClientDate"; import { FollowButton } from "~/components/FollowButton"; +import { loadUserProfile } from "./users.$username.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const db = getDb(); - const [user] = await db.select().from(users).where(eq(users.username, params.username)); - - if (!user) { - throw data({ error: "User not found" }, { status: 404 }); - } - - const currentUser = await getSessionUser(request); - const isOwn = currentUser?.id === user.id; - - // Follow state: null when anonymous or owner; { following, pending } - // otherwise. - const followState = !isOwn && currentUser - ? await getFollowState(currentUser.id, user.username) - : null; - - // Locked-account model: a private profile renders a stub for - // non-followers (anonymous OR signed-in but not an accepted follower). - // Owners always see their own profile in full. - const canSeeContent = - isOwn || - user.profileVisibility === "public" || - (followState !== null && followState.following === true); - - // For private-stub viewers we still want counts (cheap) but skip the - // expensive content fetches. - const [followers, following] = await Promise.all([ - countFollowers(user.id), - countFollowing(user.id), - ]); - const url = new URL(request.url); - const sortParam = url.searchParams.get("sort"); - const activitySort = sortParam === "addedAt" ? "addedAt" : "startedAt"; - - const [publicRoutes, publicActivities] = canSeeContent - ? await Promise.all([ - listPublicRoutesForOwner(user.id), - listPublicActivitiesForOwner(user.id, activitySort), - ]) - : [[], []]; - - // Demo-account badge: true when this profile matches the instance's - // configured demo persona username. Computed server-side so we don't - // ship the persona config through client HTML. - const isDemoUser = user.username === loadPersona().username; - - return data({ - user: { - username: user.username, - displayName: user.displayName, - bio: user.bio, - domain: user.domain, - createdAt: user.createdAt.toISOString(), - }, - routes: publicRoutes.map((r) => ({ - id: r.id, - name: r.name, - description: r.description, - distance: r.distance, - elevationGain: r.elevationGain, - updatedAt: r.updatedAt.toISOString(), - })), - activities: publicActivities.map((a) => ({ - id: a.id, - name: a.name, - description: a.description, - distance: a.distance, - duration: a.duration, - startedAt: a.startedAt?.toISOString() ?? null, - createdAt: a.createdAt.toISOString(), - })), - activitySort, - isOwn, - isDemoUser, - followers, - following, - followState, - isLoggedIn: currentUser !== null, - profileVisibility: user.profileVisibility, - canSeeContent, - }); + return data(await loadUserProfile(request, params.username)); } export function meta({ data: loaderData }: Route.MetaArgs) {