From df562742e1dd07d24f1980cfd920efa0cae3693a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 24 May 2026 11:05:40 +0200 Subject: [PATCH] fix(journal): extract loaders/actions for the remaining 21 mixed routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the .server.ts split started in #418. Every route that mixes a default-export component with a server-only loader/action now has a sibling .server.ts holding the data-fetching helpers; the route .tsx is a thin delegator. Routes converted (21): activities._index, activities.$id, activities.new, auth.accept-terms, auth.verify, explore, feed, notifications, routes._index, routes.$id, routes.$id.edit, routes.new, settings, settings.account, settings.connections.komoot, settings.profile, settings.security, sync.import.$provider, sync.import.komoot, users.$username.followers, users.$username.following Pattern (same as home.tsx / users.$username.tsx / settings.connections.tsx): - loader → `return data(await loadX(request, params?))` - action → `return await xAction(request, params?)` - All `getDb` / Drizzle schema / `~/lib/*.server` imports move to the .server.ts sibling. - `throw redirect(...)` and `throw data(...)` propagate through the delegator unchanged. No behavior changes — pure module-graph cleanup. Component modules no longer transitively import the DB client; Vite's tree-shake of server-only code is now backed by an explicit, file-local contract. Verified: - pnpm typecheck — green - pnpm lint — green - pnpm test — 181 passed, 31 integration-gated skipped - pnpm --filter @trails-cool/journal build — succeeds Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/routes/activities.$id.server.ts | 95 ++++++++++ apps/journal/app/routes/activities.$id.tsx | 84 +-------- .../app/routes/activities._index.server.ts | 27 +++ apps/journal/app/routes/activities._index.tsx | 24 +-- .../app/routes/activities.new.server.ts | 41 +++++ apps/journal/app/routes/activities.new.tsx | 37 +--- .../app/routes/auth.accept-terms.server.ts | 47 +++++ apps/journal/app/routes/auth.accept-terms.tsx | 42 +---- apps/journal/app/routes/auth.verify.server.ts | 35 ++++ apps/journal/app/routes/auth.verify.tsx | 32 +--- apps/journal/app/routes/explore.server.ts | 74 ++++++++ apps/journal/app/routes/explore.tsx | 71 +------- apps/journal/app/routes/feed.server.ts | 36 ++++ apps/journal/app/routes/feed.tsx | 34 +--- .../app/routes/notifications.server.ts | 111 ++++++++++++ apps/journal/app/routes/notifications.tsx | 92 +--------- .../app/routes/routes.$id.edit.server.ts | 49 +++++ apps/journal/app/routes/routes.$id.edit.tsx | 44 +---- apps/journal/app/routes/routes.$id.server.ts | 170 ++++++++++++++++++ apps/journal/app/routes/routes.$id.tsx | 165 +---------------- .../app/routes/routes._index.server.ts | 20 +++ apps/journal/app/routes/routes._index.tsx | 17 +- apps/journal/app/routes/routes.new.server.ts | 29 +++ apps/journal/app/routes/routes.new.tsx | 25 +-- .../app/routes/settings.account.server.ts | 15 ++ apps/journal/app/routes/settings.account.tsx | 13 +- .../settings.connections.komoot.server.ts | 23 +++ .../routes/settings.connections.komoot.tsx | 21 +-- .../app/routes/settings.profile.server.ts | 17 ++ apps/journal/app/routes/settings.profile.tsx | 15 +- .../app/routes/settings.security.server.ts | 33 ++++ apps/journal/app/routes/settings.security.tsx | 31 +--- apps/journal/app/routes/settings.server.ts | 10 ++ apps/journal/app/routes/settings.tsx | 8 +- .../routes/sync.import.$provider.server.ts | 118 ++++++++++++ .../app/routes/sync.import.$provider.tsx | 114 +----------- .../app/routes/sync.import.komoot.server.ts | 54 ++++++ .../journal/app/routes/sync.import.komoot.tsx | 50 +----- .../users.$username.followers.server.ts | 46 +++++ .../app/routes/users.$username.followers.tsx | 42 +---- .../users.$username.following.server.ts | 45 +++++ .../app/routes/users.$username.following.tsx | 41 +---- 42 files changed, 1160 insertions(+), 937 deletions(-) create mode 100644 apps/journal/app/routes/activities.$id.server.ts create mode 100644 apps/journal/app/routes/activities._index.server.ts create mode 100644 apps/journal/app/routes/activities.new.server.ts create mode 100644 apps/journal/app/routes/auth.accept-terms.server.ts create mode 100644 apps/journal/app/routes/auth.verify.server.ts create mode 100644 apps/journal/app/routes/explore.server.ts create mode 100644 apps/journal/app/routes/feed.server.ts create mode 100644 apps/journal/app/routes/notifications.server.ts create mode 100644 apps/journal/app/routes/routes.$id.edit.server.ts create mode 100644 apps/journal/app/routes/routes.$id.server.ts create mode 100644 apps/journal/app/routes/routes._index.server.ts create mode 100644 apps/journal/app/routes/routes.new.server.ts create mode 100644 apps/journal/app/routes/settings.account.server.ts create mode 100644 apps/journal/app/routes/settings.connections.komoot.server.ts create mode 100644 apps/journal/app/routes/settings.profile.server.ts create mode 100644 apps/journal/app/routes/settings.security.server.ts create mode 100644 apps/journal/app/routes/settings.server.ts create mode 100644 apps/journal/app/routes/sync.import.$provider.server.ts create mode 100644 apps/journal/app/routes/sync.import.komoot.server.ts create mode 100644 apps/journal/app/routes/users.$username.followers.server.ts create mode 100644 apps/journal/app/routes/users.$username.following.server.ts diff --git a/apps/journal/app/routes/activities.$id.server.ts b/apps/journal/app/routes/activities.$id.server.ts new file mode 100644 index 0000000..b06578f --- /dev/null +++ b/apps/journal/app/routes/activities.$id.server.ts @@ -0,0 +1,95 @@ +// Server-only loader/action for /activities/:id. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { canView } from "~/lib/auth.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"; +import type { Visibility } from "@trails-cool/db/schema/journal"; + +const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); + +export async function loadActivityDetail(request: Request, id: string | undefined) { + const activity = await getActivity(id ?? ""); + if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); + + const user = await getSessionUser(request); + const isOwner = user?.id === activity.ownerId; + + // Visibility gate — public always, unlisted on direct link, private owner-only. + // 404 (not 403) to avoid leaking existence. + if (!canView(activity, user, { asDirectLink: true })) { + throw data({ error: "Activity not found" }, { status: 404 }); + } + + const userRoutes = isOwner && user ? await listRoutes(user.id) : []; + + return { + activity: { + id: activity.id, + name: activity.name, + description: activity.description, + distance: activity.distance, + elevationGain: activity.elevationGain, + elevationLoss: activity.elevationLoss, + duration: activity.duration, + routeId: activity.routeId, + hasGpx: !!activity.gpx, + geojson: activity.geojson ?? null, + startedAt: activity.startedAt?.toISOString() ?? null, + visibility: activity.visibility, + createdAt: activity.createdAt.toISOString(), + importSource: activity.importSource, + }, + isOwner, + routes: userRoutes.map((r) => ({ id: r.id, name: r.name })), + }; +} + +export async function activityDetailAction(request: Request, id: string | undefined) { + const user = await requireSessionUser(request); + const activityId = id ?? ""; + + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "link-route") { + const routeId = formData.get("routeId") as string; + if (routeId) { + await linkActivityToRoute(activityId, routeId, user.id); + } + return redirect(`/activities/${activityId}`); + } + + if (intent === "create-route") { + const routeId = await createRouteFromActivity(activityId, user.id); + if (routeId) return redirect(`/routes/${routeId}`); + return data({ error: "No GPX data to create route from" }, { status: 400 }); + } + + if (intent === "delete") { + await deleteImportByActivity(activityId); + const deleted = await deleteActivity(activityId, user.id); + if (deleted) return redirect("/activities"); + return data({ error: "Activity not found" }, { status: 404 }); + } + + if (intent === "set-visibility") { + const raw = formData.get("visibility") as string | null; + if (!raw || !VISIBILITY_VALUES.has(raw as Visibility)) { + return data({ error: "Invalid visibility" }, { status: 400 }); + } + const ok = await updateActivityVisibility(activityId, user.id, raw as Visibility); + if (!ok) return data({ error: "Activity not found" }, { status: 404 }); + return redirect(`/activities/${activityId}`); + } + + return data({ error: "Unknown action" }, { status: 400 }); +} diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 715f29e..fa189dd 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -1,92 +1,16 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities.$id"; -import { canView } from "~/lib/auth.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"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; -import type { Visibility } from "@trails-cool/db/schema/journal"; - -const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); +import { loadActivityDetail, activityDetailAction } from "./activities.$id.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const activity = await getActivity(params.id); - if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); - - const user = await getSessionUser(request); - const isOwner = user?.id === activity.ownerId; - - // Visibility gate — public always, unlisted on direct link, private owner-only. - // 404 (not 403) to avoid leaking existence. - if (!canView(activity, user, { asDirectLink: true })) { - throw data({ error: "Activity not found" }, { status: 404 }); - } - - const userRoutes = isOwner && user ? await listRoutes(user.id) : []; - - return data({ - activity: { - id: activity.id, - name: activity.name, - description: activity.description, - distance: activity.distance, - elevationGain: activity.elevationGain, - elevationLoss: activity.elevationLoss, - duration: activity.duration, - routeId: activity.routeId, - hasGpx: !!activity.gpx, - geojson: activity.geojson ?? null, - startedAt: activity.startedAt?.toISOString() ?? null, - visibility: activity.visibility, - createdAt: activity.createdAt.toISOString(), - importSource: activity.importSource, - }, - isOwner, - routes: userRoutes.map((r) => ({ id: r.id, name: r.name })), - }); + return data(await loadActivityDetail(request, params.id)); } export async function action({ params, request }: Route.ActionArgs) { - const user = await requireSessionUser(request); - - const formData = await request.formData(); - const intent = formData.get("intent"); - - if (intent === "link-route") { - const routeId = formData.get("routeId") as string; - if (routeId) { - await linkActivityToRoute(params.id, routeId, user.id); - } - return redirect(`/activities/${params.id}`); - } - - if (intent === "create-route") { - const routeId = await createRouteFromActivity(params.id, user.id); - if (routeId) return redirect(`/routes/${routeId}`); - return data({ error: "No GPX data to create route from" }, { status: 400 }); - } - - if (intent === "delete") { - await deleteImportByActivity(params.id); - const deleted = await deleteActivity(params.id, user.id); - if (deleted) return redirect("/activities"); - return data({ error: "Activity not found" }, { status: 404 }); - } - - if (intent === "set-visibility") { - const raw = formData.get("visibility") as string | null; - if (!raw || !VISIBILITY_VALUES.has(raw as Visibility)) { - return data({ error: "Invalid visibility" }, { status: 400 }); - } - const ok = await updateActivityVisibility(params.id, user.id, raw as Visibility); - if (!ok) return data({ error: "Activity not found" }, { status: 404 }); - return redirect(`/activities/${params.id}`); - } - - return data({ error: "Unknown action" }, { status: 400 }); + return await activityDetailAction(request, params.id); } export function meta({ data: loaderData }: Route.MetaArgs) { diff --git a/apps/journal/app/routes/activities._index.server.ts b/apps/journal/app/routes/activities._index.server.ts new file mode 100644 index 0000000..f7277f4 --- /dev/null +++ b/apps/journal/app/routes/activities._index.server.ts @@ -0,0 +1,27 @@ +// Server-only loader for /activities index. See `home.server.ts`. + +import { requireSessionUser } from "~/lib/auth/session.server"; +import { listActivities } from "~/lib/activities.server"; + +export async function loadActivitiesIndex(request: Request) { + const user = await requireSessionUser(request); + + const url = new URL(request.url); + const sortParam = url.searchParams.get("sort"); + const activitySort = sortParam === "addedAt" ? "addedAt" : ("startedAt" as const); + + const userActivities = await listActivities(user.id, activitySort); + return { + activitySort, + activities: userActivities.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, + })), + }; +} diff --git a/apps/journal/app/routes/activities._index.tsx b/apps/journal/app/routes/activities._index.tsx index 4056c73..e5575ec 100644 --- a/apps/journal/app/routes/activities._index.tsx +++ b/apps/journal/app/routes/activities._index.tsx @@ -1,32 +1,12 @@ import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities._index"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { listActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; +import { loadActivitiesIndex } from "./activities._index.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await requireSessionUser(request); - - const url = new URL(request.url); - const sortParam = url.searchParams.get("sort"); - const activitySort = sortParam === "addedAt" ? "addedAt" : ("startedAt" as const); - - const userActivities = await listActivities(user.id, activitySort); - return data({ - activitySort, - activities: userActivities.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, - })), - }); + return data(await loadActivitiesIndex(request)); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/activities.new.server.ts b/apps/journal/app/routes/activities.new.server.ts new file mode 100644 index 0000000..c288f05 --- /dev/null +++ b/apps/journal/app/routes/activities.new.server.ts @@ -0,0 +1,41 @@ +// Server-only loader/action for /activities/new. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { createActivity } from "~/lib/activities.server"; +import { listRoutes } from "~/lib/routes.server"; + +export async function loadActivitiesNew(request: Request) { + const user = await requireSessionUser(request); + + const userRoutes = await listRoutes(user.id); + return { + routes: userRoutes.map((r) => ({ id: r.id, name: r.name })), + }; +} + +export async function activitiesNewAction(request: Request) { + const user = await requireSessionUser(request); + + const formData = await request.formData(); + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const routeId = formData.get("routeId") as string | null; + const gpxFile = formData.get("gpx") as File | null; + + if (!name) return data({ error: "Name is required" }, { status: 400 }); + + let gpx: string | undefined; + if (gpxFile && gpxFile.size > 0) { + gpx = await gpxFile.text(); + } + + const activityId = await createActivity(user.id, { + name, + description, + gpx, + routeId: routeId || undefined, + }); + + return redirect(`/activities/${activityId}`); +} diff --git a/apps/journal/app/routes/activities.new.tsx b/apps/journal/app/routes/activities.new.tsx index fc55122..cd66ee1 100644 --- a/apps/journal/app/routes/activities.new.tsx +++ b/apps/journal/app/routes/activities.new.tsx @@ -1,42 +1,13 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import type { Route } from "./+types/activities.new"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { createActivity } from "~/lib/activities.server"; -import { listRoutes } from "~/lib/routes.server"; +import { loadActivitiesNew, activitiesNewAction } from "./activities.new.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await requireSessionUser(request); - - const userRoutes = await listRoutes(user.id); - return data({ - routes: userRoutes.map((r) => ({ id: r.id, name: r.name })), - }); + return data(await loadActivitiesNew(request)); } export async function action({ request }: Route.ActionArgs) { - const user = await requireSessionUser(request); - - const formData = await request.formData(); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const routeId = formData.get("routeId") as string | null; - const gpxFile = formData.get("gpx") as File | null; - - if (!name) return data({ error: "Name is required" }, { status: 400 }); - - let gpx: string | undefined; - if (gpxFile && gpxFile.size > 0) { - gpx = await gpxFile.text(); - } - - const activityId = await createActivity(user.id, { - name, - description, - gpx, - routeId: routeId || undefined, - }); - - return redirect(`/activities/${activityId}`); + return await activitiesNewAction(request); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/auth.accept-terms.server.ts b/apps/journal/app/routes/auth.accept-terms.server.ts new file mode 100644 index 0000000..d99ca71 --- /dev/null +++ b/apps/journal/app/routes/auth.accept-terms.server.ts @@ -0,0 +1,47 @@ +// Server-only loader/action for /auth/accept-terms. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { recordTermsAcceptance } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { TERMS_VERSION } from "~/lib/legal"; + +/** + * Paths we'll bounce back to after a successful acceptance. We only allow + * same-origin absolute paths to avoid being used as an open redirect. + */ +function safeReturnTo(raw: string | null): string { + if (!raw) return "/"; + if (!raw.startsWith("/") || raw.startsWith("//")) return "/"; + return raw; +} + +export async function loadAcceptTerms(request: Request) { + const user = await getSessionUser(request); + if (!user) { + throw redirect("/auth/login"); + } + // If the user is already current, bounce them back (e.g. double-submit). + if (user.termsVersion === TERMS_VERSION) { + const returnTo = safeReturnTo(new URL(request.url).searchParams.get("returnTo")); + throw redirect(returnTo); + } + return { previousVersion: user.termsVersion }; +} + +export async function acceptTermsAction(request: Request) { + const user = await getSessionUser(request); + if (!user) { + throw redirect("/auth/login"); + } + + const form = await request.formData(); + const accepted = form.get("termsAccepted") === "on" || form.get("termsAccepted") === "true"; + if (!accepted) { + return data({ error: "Terms of Service must be accepted to continue" }, { status: 400 }); + } + + await recordTermsAcceptance(user.id, TERMS_VERSION); + + const returnTo = safeReturnTo(form.get("returnTo")?.toString() ?? null); + throw redirect(returnTo); +} diff --git a/apps/journal/app/routes/auth.accept-terms.tsx b/apps/journal/app/routes/auth.accept-terms.tsx index 81da750..1059e7f 100644 --- a/apps/journal/app/routes/auth.accept-terms.tsx +++ b/apps/journal/app/routes/auth.accept-terms.tsx @@ -1,10 +1,9 @@ import { useState } from "react"; -import { Form, data, redirect, useLoaderData, useSearchParams } from "react-router"; +import { Form, useLoaderData, useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/auth.accept-terms"; -import { recordTermsAcceptance } from "~/lib/auth.server"; -import { getSessionUser } from "~/lib/auth/session.server"; import { TERMS_VERSION } from "~/lib/legal"; +import { loadAcceptTerms, acceptTermsAction } from "./auth.accept-terms.server"; export function meta() { return [ @@ -13,45 +12,12 @@ export function meta() { ]; } -/** - * Paths we'll bounce back to after a successful acceptance. We only allow - * same-origin absolute paths to avoid being used as an open redirect. - */ -function safeReturnTo(raw: string | null): string { - if (!raw) return "/"; - if (!raw.startsWith("/") || raw.startsWith("//")) return "/"; - return raw; -} - export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) { - throw redirect("/auth/login"); - } - // If the user is already current, bounce them back (e.g. double-submit). - if (user.termsVersion === TERMS_VERSION) { - const returnTo = safeReturnTo(new URL(request.url).searchParams.get("returnTo")); - throw redirect(returnTo); - } - return { previousVersion: user.termsVersion }; + return await loadAcceptTerms(request); } export async function action({ request }: Route.ActionArgs) { - const user = await getSessionUser(request); - if (!user) { - throw redirect("/auth/login"); - } - - const form = await request.formData(); - const accepted = form.get("termsAccepted") === "on" || form.get("termsAccepted") === "true"; - if (!accepted) { - return data({ error: "Terms of Service must be accepted to continue" }, { status: 400 }); - } - - await recordTermsAcceptance(user.id, TERMS_VERSION); - - const returnTo = safeReturnTo(form.get("returnTo")?.toString() ?? null); - throw redirect(returnTo); + return await acceptTermsAction(request); } export default function AcceptTermsPage() { diff --git a/apps/journal/app/routes/auth.verify.server.ts b/apps/journal/app/routes/auth.verify.server.ts new file mode 100644 index 0000000..62d4d55 --- /dev/null +++ b/apps/journal/app/routes/auth.verify.server.ts @@ -0,0 +1,35 @@ +// Server-only loader for /auth/verify. See `home.server.ts` for the pattern. + +import { redirect, data } from "react-router"; +import { verifyMagicToken, verifyEmailChange } from "~/lib/auth.server"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { completeAuth } from "~/lib/auth/completion.server"; + +export async function loadAuthVerify(request: Request) { + const url = new URL(request.url); + const token = url.searchParams.get("token"); + const isEmailChange = url.searchParams.get("email-change") === "1"; + + if (!token) { + return data({ error: "Missing token" }, { status: 400 }); + } + + try { + if (isEmailChange) { + const user = await requireSessionUser(request); + await verifyEmailChange(token, user.id); + return redirect("/settings/account"); + } + + const userId = await verifyMagicToken(token); + // Default destination after magic-link sign-in is "/?add-passkey=1" + // (prompt to set up a passkey now that they're in). If the link + // carried a returnTo, completeAuth's safeReturnTo will honor any + // same-origin path and otherwise fall back to "/" — handle the + // add-passkey default before delegating. + const returnTo = url.searchParams.get("returnTo") ?? "/?add-passkey=1"; + return completeAuth({ userId, request, returnTo, mode: "redirect" }); + } catch (e) { + return data({ error: (e as Error).message }, { status: 400 }); + } +} diff --git a/apps/journal/app/routes/auth.verify.tsx b/apps/journal/app/routes/auth.verify.tsx index c122a5a..c4803fc 100644 --- a/apps/journal/app/routes/auth.verify.tsx +++ b/apps/journal/app/routes/auth.verify.tsx @@ -1,36 +1,8 @@ -import { redirect, data } from "react-router"; import type { Route } from "./+types/auth.verify"; -import { verifyMagicToken, verifyEmailChange } from "~/lib/auth.server"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { completeAuth } from "~/lib/auth/completion.server"; +import { loadAuthVerify } from "./auth.verify.server"; export async function loader({ request }: Route.LoaderArgs) { - const url = new URL(request.url); - const token = url.searchParams.get("token"); - const isEmailChange = url.searchParams.get("email-change") === "1"; - - if (!token) { - return data({ error: "Missing token" }, { status: 400 }); - } - - try { - if (isEmailChange) { - const user = await requireSessionUser(request); - await verifyEmailChange(token, user.id); - return redirect("/settings/account"); - } - - const userId = await verifyMagicToken(token); - // Default destination after magic-link sign-in is "/?add-passkey=1" - // (prompt to set up a passkey now that they're in). If the link - // carried a returnTo, completeAuth's safeReturnTo will honor any - // same-origin path and otherwise fall back to "/" — handle the - // add-passkey default before delegating. - const returnTo = url.searchParams.get("returnTo") ?? "/?add-passkey=1"; - return completeAuth({ userId, request, returnTo, mode: "redirect" }); - } catch (e) { - return data({ error: (e as Error).message }, { status: 400 }); - } + return await loadAuthVerify(request); } export default function VerifyPage() { diff --git a/apps/journal/app/routes/explore.server.ts b/apps/journal/app/routes/explore.server.ts new file mode 100644 index 0000000..918796a --- /dev/null +++ b/apps/journal/app/routes/explore.server.ts @@ -0,0 +1,74 @@ +// Server-only loader for /explore. See `home.server.ts`. + +import { getSessionUser } from "~/lib/auth/session.server"; +import { + EXPLORE_DEFAULT_PAGE_SIZE, + countFollowersBatch, + getFollowStateBatch, + listActiveRecently, + listDirectory, +} from "~/lib/explore.server"; +import { loadPersona } from "~/lib/demo-bot.server"; + +const BIO_TRUNCATE = 120; + +function truncateBio(bio: string | null): string | null { + if (!bio) return null; + const trimmed = bio.trim(); + if (trimmed.length === 0) return null; + if (trimmed.length <= BIO_TRUNCATE) return trimmed; + return trimmed.slice(0, BIO_TRUNCATE).trimEnd() + "…"; +} + +export async function loadExplore(request: Request) { + const viewer = await getSessionUser(request); + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") ?? "1"); + const perPage = Number(url.searchParams.get("perPage") ?? String(EXPLORE_DEFAULT_PAGE_SIZE)); + + const [activeRecently, directory] = await Promise.all([ + listActiveRecently(), + listDirectory({ page, perPage }), + ]); + + // Per-row data: follower count (for everyone) + follow state (for + // signed-in viewers only). Both are batched so the page issues at + // most two extra queries regardless of page size. + const allRows = [...activeRecently, ...directory.rows]; + const allIds = allRows.map((r) => r.id); + const followerCounts = await countFollowersBatch(allIds); + const followStates = viewer + ? await getFollowStateBatch(viewer.id, allRows.map((r) => ({ id: r.id, username: r.username }))) + : new Map(); + + const isSelf = (rowId: string) => viewer?.id === rowId; + const personaUsername = loadPersona().username; + + const decorate = (row: typeof allRows[number]) => ({ + id: row.id, + username: row.username, + displayName: row.displayName, + bio: truncateBio(row.bio), + followerCount: followerCounts.get(row.id) ?? 0, + followState: followStates.get(row.id) ?? null, + isSelf: isSelf(row.id), + isDemoUser: row.username === personaUsername, + }); + + // Resolved page size (after loader-side clamping inside listDirectory) + // for the pagination math here. We can compute totalPages without + // re-querying since `directory.totalCount` is authoritative. + const resolvedPerPage = Math.max(1, Math.min(100, Math.floor(Number.isFinite(perPage) ? perPage : EXPLORE_DEFAULT_PAGE_SIZE))); + const resolvedPage = Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)); + const totalPages = Math.max(1, Math.ceil(directory.totalCount / resolvedPerPage)); + + return { + isSignedIn: !!viewer, + activeRecently: activeRecently.map(decorate), + directory: directory.rows.map(decorate), + page: resolvedPage, + perPage: resolvedPerPage, + totalPages, + totalCount: directory.totalCount, + }; +} diff --git a/apps/journal/app/routes/explore.tsx b/apps/journal/app/routes/explore.tsx index 6386a80..a25075a 100644 --- a/apps/journal/app/routes/explore.tsx +++ b/apps/journal/app/routes/explore.tsx @@ -2,78 +2,11 @@ import { data } from "react-router"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/explore"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { - EXPLORE_DEFAULT_PAGE_SIZE, - countFollowersBatch, - getFollowStateBatch, - listActiveRecently, - listDirectory, -} from "~/lib/explore.server"; -import { loadPersona } from "~/lib/demo-bot.server"; import { FollowButton } from "~/components/FollowButton"; - -const BIO_TRUNCATE = 120; - -function truncateBio(bio: string | null): string | null { - if (!bio) return null; - const trimmed = bio.trim(); - if (trimmed.length === 0) return null; - if (trimmed.length <= BIO_TRUNCATE) return trimmed; - return trimmed.slice(0, BIO_TRUNCATE).trimEnd() + "…"; -} +import { loadExplore } from "./explore.server"; export async function loader({ request }: Route.LoaderArgs) { - const viewer = await getSessionUser(request); - const url = new URL(request.url); - const page = Number(url.searchParams.get("page") ?? "1"); - const perPage = Number(url.searchParams.get("perPage") ?? String(EXPLORE_DEFAULT_PAGE_SIZE)); - - const [activeRecently, directory] = await Promise.all([ - listActiveRecently(), - listDirectory({ page, perPage }), - ]); - - // Per-row data: follower count (for everyone) + follow state (for - // signed-in viewers only). Both are batched so the page issues at - // most two extra queries regardless of page size. - const allRows = [...activeRecently, ...directory.rows]; - const allIds = allRows.map((r) => r.id); - const followerCounts = await countFollowersBatch(allIds); - const followStates = viewer - ? await getFollowStateBatch(viewer.id, allRows.map((r) => ({ id: r.id, username: r.username }))) - : new Map(); - - const isSelf = (rowId: string) => viewer?.id === rowId; - const personaUsername = loadPersona().username; - - const decorate = (row: typeof allRows[number]) => ({ - id: row.id, - username: row.username, - displayName: row.displayName, - bio: truncateBio(row.bio), - followerCount: followerCounts.get(row.id) ?? 0, - followState: followStates.get(row.id) ?? null, - isSelf: isSelf(row.id), - isDemoUser: row.username === personaUsername, - }); - - // Resolved page size (after loader-side clamping inside listDirectory) - // for the pagination math here. We can compute totalPages without - // re-querying since `directory.totalCount` is authoritative. - const resolvedPerPage = Math.max(1, Math.min(100, Math.floor(Number.isFinite(perPage) ? perPage : EXPLORE_DEFAULT_PAGE_SIZE))); - const resolvedPage = Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)); - const totalPages = Math.max(1, Math.ceil(directory.totalCount / resolvedPerPage)); - - return data({ - isSignedIn: !!viewer, - activeRecently: activeRecently.map(decorate), - directory: directory.rows.map(decorate), - page: resolvedPage, - perPage: resolvedPerPage, - totalPages, - totalCount: directory.totalCount, - }); + return data(await loadExplore(request)); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/feed.server.ts b/apps/journal/app/routes/feed.server.ts new file mode 100644 index 0000000..1f5e7c8 --- /dev/null +++ b/apps/journal/app/routes/feed.server.ts @@ -0,0 +1,36 @@ +// Server-only loader for /feed. See `home.server.ts`. + +import { redirect } from "react-router"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { listSocialFeed, listRecentPublicActivities } from "~/lib/activities.server"; + +type View = "followed" | "public"; + +export async function loadFeed(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const url = new URL(request.url); + const view: View = url.searchParams.get("view") === "public" ? "public" : "followed"; + + const rows = + view === "public" + ? await listRecentPublicActivities(50) + : await listSocialFeed(user.id, 50); + + return { + view, + 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, + })), + }; +} diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index 4590144..37f5b67 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -1,41 +1,13 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/feed"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { listSocialFeed, listRecentPublicActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; - -type View = "followed" | "public"; +import { loadFeed } from "./feed.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - - const url = new URL(request.url); - const view: View = url.searchParams.get("view") === "public" ? "public" : "followed"; - - const rows = - view === "public" - ? await listRecentPublicActivities(50) - : await listSocialFeed(user.id, 50); - - return data({ - view, - 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(await loadFeed(request)); } export function meta({ data: loaderData }: Route.MetaArgs) { diff --git a/apps/journal/app/routes/notifications.server.ts b/apps/journal/app/routes/notifications.server.ts new file mode 100644 index 0000000..ed47ef6 --- /dev/null +++ b/apps/journal/app/routes/notifications.server.ts @@ -0,0 +1,111 @@ +// Server-only loader for /notifications. See `home.server.ts`. + +import { redirect } from "react-router"; +import { inArray, eq, and } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { listForUser } from "~/lib/notifications.server"; +import { linkFor } from "~/lib/notifications/link-for"; +import { readPayload } from "~/lib/notifications/payload"; +import { + countPendingFollowRequests, + listPendingFollowRequests, +} from "~/lib/follow.server"; +import { activities } from "@trails-cool/db/schema/journal"; + +type Tab = "activity" | "requests"; + +export interface NotificationRow { + id: string; + type: string; + readAt: string | null; + createdAt: string; + link: string; + payload: Record | null; +} + +export interface RequestRow { + id: string; + followerUsername: string; + followerDisplayName: string | null; + followerDomain: string; + createdAt: string; +} + +export async function loadNotifications(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const url = new URL(request.url); + const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity"; + + // Pending count drives the Requests tab dot regardless of which tab is + // currently active, so we always fetch it. It's a single COUNT(*) query. + const pendingCount = await countPendingFollowRequests(user.id); + + if (tab === "requests") { + const requests = await listPendingFollowRequests(user.id); + return { + tab: "requests" as const, + pendingCount, + requests: requests.map((r) => ({ + id: r.id, + followerUsername: r.followerUsername, + followerDisplayName: r.followerDisplayName, + followerDomain: r.followerDomain, + createdAt: r.createdAt.toISOString(), + })), + notifications: [] as NotificationRow[], + nextCursor: null as string | null, + }; + } + + const before = url.searchParams.get("before") ?? undefined; + const { rows, nextCursor } = await listForUser(user.id, { before }); + + // Renderer guard: drop activity_published rows whose subject is gone + // or no longer public (visibility flipped from public → private/unlisted). + // Fetch the still-public subject IDs in one query, then filter. + const activitySubjectIds = rows + .filter((r) => r.type === "activity_published" && r.subjectId) + .map((r) => r.subjectId as string); + let publicActivityIds = new Set(); + if (activitySubjectIds.length > 0) { + const db = getDb(); + const visible = await db + .select({ id: activities.id }) + .from(activities) + .where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public"))); + publicActivityIds = new Set(visible.map((v) => v.id)); + } + + const visibleRows = rows.filter((r) => { + if (r.type !== "activity_published") return true; + if (!r.subjectId) return false; + return publicActivityIds.has(r.subjectId); + }); + + return { + tab: "activity" as const, + pendingCount, + requests: [] as RequestRow[], + notifications: visibleRows.map((r) => { + const link = linkFor({ + type: r.type, + subjectId: r.subjectId, + payload: r.payload, + payloadVersion: r.payloadVersion, + }); + const payload = readPayload(r.type, r.payloadVersion, r.payload); + return { + id: r.id, + type: r.type, + readAt: r.readAt?.toISOString() ?? null, + createdAt: r.createdAt.toISOString(), + link: link.web, + payload, + }; + }), + nextCursor, + }; +} diff --git a/apps/journal/app/routes/notifications.tsx b/apps/journal/app/routes/notifications.tsx index dda955a..13c567a 100644 --- a/apps/journal/app/routes/notifications.tsx +++ b/apps/journal/app/routes/notifications.tsx @@ -1,98 +1,12 @@ -import { data, redirect, useFetcher } from "react-router"; +import { data, useFetcher } from "react-router"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; -import { inArray, eq, and } from "drizzle-orm"; import type { Route } from "./+types/notifications"; -import { getDb } from "~/lib/db"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { listForUser } from "~/lib/notifications.server"; -import { linkFor } from "~/lib/notifications/link-for"; -import { readPayload } from "~/lib/notifications/payload"; -import { - countPendingFollowRequests, - listPendingFollowRequests, -} from "~/lib/follow.server"; import { ClientDate } from "~/components/ClientDate"; -import { activities } from "@trails-cool/db/schema/journal"; - -type Tab = "activity" | "requests"; +import { loadNotifications } from "./notifications.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - - const url = new URL(request.url); - const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity"; - - // Pending count drives the Requests tab dot regardless of which tab is - // currently active, so we always fetch it. It's a single COUNT(*) query. - const pendingCount = await countPendingFollowRequests(user.id); - - if (tab === "requests") { - const requests = await listPendingFollowRequests(user.id); - return data({ - tab: "requests" as const, - pendingCount, - requests: requests.map((r) => ({ - id: r.id, - followerUsername: r.followerUsername, - followerDisplayName: r.followerDisplayName, - followerDomain: r.followerDomain, - createdAt: r.createdAt.toISOString(), - })), - notifications: [] as NotificationRow[], - nextCursor: null as string | null, - }); - } - - const before = url.searchParams.get("before") ?? undefined; - const { rows, nextCursor } = await listForUser(user.id, { before }); - - // Renderer guard: drop activity_published rows whose subject is gone - // or no longer public (visibility flipped from public → private/unlisted). - // Fetch the still-public subject IDs in one query, then filter. - const activitySubjectIds = rows - .filter((r) => r.type === "activity_published" && r.subjectId) - .map((r) => r.subjectId as string); - let publicActivityIds = new Set(); - if (activitySubjectIds.length > 0) { - const db = getDb(); - const visible = await db - .select({ id: activities.id }) - .from(activities) - .where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public"))); - publicActivityIds = new Set(visible.map((v) => v.id)); - } - - const visibleRows = rows.filter((r) => { - if (r.type !== "activity_published") return true; - if (!r.subjectId) return false; - return publicActivityIds.has(r.subjectId); - }); - - return data({ - tab: "activity" as const, - pendingCount, - requests: [] as RequestRow[], - notifications: visibleRows.map((r) => { - const link = linkFor({ - type: r.type, - subjectId: r.subjectId, - payload: r.payload, - payloadVersion: r.payloadVersion, - }); - const payload = readPayload(r.type, r.payloadVersion, r.payload); - return { - id: r.id, - type: r.type, - readAt: r.readAt?.toISOString() ?? null, - createdAt: r.createdAt.toISOString(), - link: link.web, - payload, - }; - }), - nextCursor, - }); + return data(await loadNotifications(request)); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/routes.$id.edit.server.ts b/apps/journal/app/routes/routes.$id.edit.server.ts new file mode 100644 index 0000000..b3ed830 --- /dev/null +++ b/apps/journal/app/routes/routes.$id.edit.server.ts @@ -0,0 +1,49 @@ +// Server-only loader/action for /routes/:id/edit. See `home.server.ts`. + +import { data, redirect } from "react-router"; +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 loadRouteEdit(request: Request, id: string | undefined) { + const user = await requireSessionUser(request); + + const route = await getRoute(id ?? ""); + if (!route) throw data({ error: "Route not found" }, { status: 404 }); + if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 }); + + return { + route: { + id: route.id, + name: route.name, + description: route.description, + visibility: route.visibility, + }, + }; +} + +export async function routeEditAction(request: Request, id: string | undefined) { + const user = await requireSessionUser(request); + const routeId = id ?? ""; + + const formData = await request.formData(); + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + const visibilityRaw = formData.get("visibility") as string | null; + + const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {}; + if (name) input.name = name; + if (description !== null) input.description = description; + if (gpxFile && gpxFile.size > 0) { + input.gpx = await gpxFile.text(); + } + if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) { + input.visibility = visibilityRaw as Visibility; + } + + await updateRoute(routeId, user.id, input); + return redirect(`/routes/${routeId}`); +} diff --git a/apps/journal/app/routes/routes.$id.edit.tsx b/apps/journal/app/routes/routes.$id.edit.tsx index 1d50d92..7ab1bc0 100644 --- a/apps/journal/app/routes/routes.$id.edit.tsx +++ b/apps/journal/app/routes/routes.$id.edit.tsx @@ -1,50 +1,14 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id.edit"; -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"]); +import { loadRouteEdit, routeEditAction } from "./routes.$id.edit.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const user = await requireSessionUser(request); - - const route = await getRoute(params.id); - if (!route) throw data({ error: "Route not found" }, { status: 404 }); - if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 }); - - return data({ - route: { - id: route.id, - name: route.name, - description: route.description, - visibility: route.visibility, - }, - }); + return data(await loadRouteEdit(request, params.id)); } export async function action({ params, request }: Route.ActionArgs) { - const user = await requireSessionUser(request); - - const formData = await request.formData(); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - const visibilityRaw = formData.get("visibility") as string | null; - - const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {}; - if (name) input.name = name; - if (description !== null) input.description = description; - if (gpxFile && gpxFile.size > 0) { - input.gpx = await gpxFile.text(); - } - if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) { - input.visibility = visibilityRaw as Visibility; - } - - await updateRoute(params.id, user.id, input); - return redirect(`/routes/${params.id}`); + return await routeEditAction(request, params.id); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/routes.$id.server.ts b/apps/journal/app/routes/routes.$id.server.ts new file mode 100644 index 0000000..7edaddb --- /dev/null +++ b/apps/journal/app/routes/routes.$id.server.ts @@ -0,0 +1,170 @@ +// Server-only loader/action for /routes/:id. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { and, eq } from "drizzle-orm"; +import { canView } from "~/lib/auth.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"; +import { getService } from "~/lib/connected-services"; + +export async function loadRouteDetail(request: Request, id: string | undefined) { + const routeId = id ?? ""; + const [routeWithVersions, routeWithGeojson] = await Promise.all([ + getRouteWithVersions(routeId), + getRoute(routeId), + ]); + if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 }); + const route = routeWithVersions; + + const user = await getSessionUser(request); + const isOwner = user?.id === route.ownerId; + + // Visibility gate: public always renders, unlisted renders on direct link, + // private requires ownership. Return 404 (not 403) to avoid leaking existence. + if (!canView(route, user, { asDirectLink: true })) { + throw data({ error: "Route not found" }, { status: 404 }); + } + + // Parse GPX once for day stats and waypoint POI data + let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = []; + let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record }> = []; + if (route.gpx) { + try { + const { computeDays, parseGpxAsync } = await import("@trails-cool/gpx"); + const gpxData = await parseGpxAsync(route.gpx); + waypoints = gpxData.waypoints.map((w) => ({ + lat: w.lat, + lon: w.lon, + name: w.name, + isDayBreak: w.isDayBreak, + note: w.note, + osmId: w.osmId, + poiTags: w.poiTags as Record | undefined, + })); + if (route.dayBreaks && route.dayBreaks.length > 0) { + dayStats = computeDays(gpxData.waypoints, gpxData.tracks); + } + } catch { + // Fall back to empty + } + } + + const currentVersion = route.versions[0]?.version ?? 1; + + // Wahoo push state — only meaningful for the owner. The single + // sync_pushes row per (user, route, wahoo) carries `lastPushedVersion`, + // which we compare against the current local version to render one of + // three states: matches, local newer, or last attempt failed. + let wahooPush: + | { + canPush: boolean; + needsReauth: boolean; + currentVersion: number; + latest: { + pushedAt: string | null; + remoteId: string | null; + lastPushedVersion: number | null; + error: string | null; + } | null; + } + | null = null; + if (isOwner && user && !!route.gpx) { + const connection = await getService(user.id, "wahoo"); + let latest: + | { + pushedAt: string | null; + remoteId: string | null; + lastPushedVersion: number | null; + error: string | null; + } + | null = null; + if (connection) { + const db = getDb(); + const [row] = await db + .select() + .from(syncPushes) + .where( + and( + eq(syncPushes.userId, user.id), + eq(syncPushes.routeId, route.id), + eq(syncPushes.provider, "wahoo"), + ), + ) + .limit(1); + latest = row + ? { + pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null, + remoteId: row.remoteId, + lastPushedVersion: row.lastPushedVersion, + error: row.error, + } + : null; + } + wahooPush = { + canPush: !!connection, + needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"), + currentVersion, + latest, + }; + } + + return { + route: { + id: route.id, + name: route.name, + description: route.description, + distance: route.distance, + elevationGain: route.elevationGain, + elevationLoss: route.elevationLoss, + routingProfile: route.routingProfile, + hasGpx: !!route.gpx, + dayBreaks: route.dayBreaks ?? [], + geojson: routeWithGeojson?.geojson ?? null, + visibility: route.visibility, + createdAt: route.createdAt.toISOString(), + updatedAt: route.updatedAt.toISOString(), + }, + dayStats, + waypoints, + versions: route.versions.map((v) => ({ + version: v.version, + changeDescription: v.changeDescription, + createdAt: v.createdAt.toISOString(), + })), + isOwner, + wahooPush, + }; +} + +export async function routeDetailAction(request: Request, id: string | undefined) { + const user = await requireSessionUser(request); + const routeId = id ?? ""; + + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "delete") { + await deleteRoute(routeId, user.id); + return redirect("/routes"); + } + + if (intent === "update") { + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + + const input: Record = {}; + if (name) input.name = name; + if (description !== null) input.description = description; + if (gpxFile && gpxFile.size > 0) { + input.gpx = await gpxFile.text(); + } + + await updateRoute(routeId, user.id, input as { name?: string; description?: string; gpx?: string }); + return redirect(`/routes/${routeId}`); + } + + return data({ error: "Unknown action" }, { status: 400 }); +} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index c7df64f..4579444 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -1,174 +1,17 @@ import { useState, useCallback } from "react"; -import { data, redirect } from "react-router"; +import { data } from "react-router"; 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, 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"; -import { getService } from "~/lib/connected-services"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; - +import { loadRouteDetail, routeDetailAction } from "./routes.$id.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const [routeWithVersions, routeWithGeojson] = await Promise.all([ - getRouteWithVersions(params.id), - getRoute(params.id), - ]); - if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 }); - const route = routeWithVersions; - - const user = await getSessionUser(request); - const isOwner = user?.id === route.ownerId; - - // Visibility gate: public always renders, unlisted renders on direct link, - // private requires ownership. Return 404 (not 403) to avoid leaking existence. - if (!canView(route, user, { asDirectLink: true })) { - throw data({ error: "Route not found" }, { status: 404 }); - } - - // Parse GPX once for day stats and waypoint POI data - let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = []; - let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record }> = []; - if (route.gpx) { - try { - const { computeDays, parseGpxAsync } = await import("@trails-cool/gpx"); - const gpxData = await parseGpxAsync(route.gpx); - waypoints = gpxData.waypoints.map((w) => ({ - lat: w.lat, - lon: w.lon, - name: w.name, - isDayBreak: w.isDayBreak, - note: w.note, - osmId: w.osmId, - poiTags: w.poiTags as Record | undefined, - })); - if (route.dayBreaks && route.dayBreaks.length > 0) { - dayStats = computeDays(gpxData.waypoints, gpxData.tracks); - } - } catch { - // Fall back to empty - } - } - - const currentVersion = route.versions[0]?.version ?? 1; - - // Wahoo push state — only meaningful for the owner. The single - // sync_pushes row per (user, route, wahoo) carries `lastPushedVersion`, - // which we compare against the current local version to render one of - // three states: matches, local newer, or last attempt failed. - let wahooPush: - | { - canPush: boolean; - needsReauth: boolean; - currentVersion: number; - latest: { - pushedAt: string | null; - remoteId: string | null; - lastPushedVersion: number | null; - error: string | null; - } | null; - } - | null = null; - if (isOwner && user && !!route.gpx) { - const connection = await getService(user.id, "wahoo"); - let latest: - | { - pushedAt: string | null; - remoteId: string | null; - lastPushedVersion: number | null; - error: string | null; - } - | null = null; - if (connection) { - const db = getDb(); - const [row] = await db - .select() - .from(syncPushes) - .where( - and( - eq(syncPushes.userId, user.id), - eq(syncPushes.routeId, route.id), - eq(syncPushes.provider, "wahoo"), - ), - ) - .limit(1); - latest = row - ? { - pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null, - remoteId: row.remoteId, - lastPushedVersion: row.lastPushedVersion, - error: row.error, - } - : null; - } - wahooPush = { - canPush: !!connection, - needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"), - currentVersion, - latest, - }; - } - - return data({ - route: { - id: route.id, - name: route.name, - description: route.description, - distance: route.distance, - elevationGain: route.elevationGain, - elevationLoss: route.elevationLoss, - routingProfile: route.routingProfile, - hasGpx: !!route.gpx, - dayBreaks: route.dayBreaks ?? [], - geojson: routeWithGeojson?.geojson ?? null, - visibility: route.visibility, - createdAt: route.createdAt.toISOString(), - updatedAt: route.updatedAt.toISOString(), - }, - dayStats, - waypoints, - versions: route.versions.map((v) => ({ - version: v.version, - changeDescription: v.changeDescription, - createdAt: v.createdAt.toISOString(), - })), - isOwner, - wahooPush, - }); + return data(await loadRouteDetail(request, params.id)); } export async function action({ params, request }: Route.ActionArgs) { - const user = await requireSessionUser(request); - - const formData = await request.formData(); - const intent = formData.get("intent"); - - if (intent === "delete") { - await deleteRoute(params.id, user.id); - return redirect("/routes"); - } - - if (intent === "update") { - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - - const input: Record = {}; - if (name) input.name = name; - if (description !== null) input.description = description; - if (gpxFile && gpxFile.size > 0) { - input.gpx = await gpxFile.text(); - } - - await updateRoute(params.id, user.id, input as { name?: string; description?: string; gpx?: string }); - return redirect(`/routes/${params.id}`); - } - - return data({ error: "Unknown action" }, { status: 400 }); + return routeDetailAction(request, params.id); } export function meta({ data: loaderData }: Route.MetaArgs) { diff --git a/apps/journal/app/routes/routes._index.server.ts b/apps/journal/app/routes/routes._index.server.ts new file mode 100644 index 0000000..55b1432 --- /dev/null +++ b/apps/journal/app/routes/routes._index.server.ts @@ -0,0 +1,20 @@ +// Server-only loader for /routes index. See `home.server.ts`. + +import { requireSessionUser } from "~/lib/auth/session.server"; +import { listRoutes } from "~/lib/routes.server"; + +export async function loadRoutesIndex(request: Request) { + const user = await requireSessionUser(request); + + const userRoutes = await listRoutes(user.id); + return { + routes: userRoutes.map((r) => ({ + id: r.id, + name: r.name, + distance: r.distance, + elevationGain: r.elevationGain, + updatedAt: r.updatedAt.toISOString(), + geojson: r.geojson ?? null, + })), + }; +} diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index fe3938a..dd67556 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -1,25 +1,12 @@ import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes._index"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; +import { loadRoutesIndex } from "./routes._index.server"; export async function loader({ request }: Route.LoaderArgs) { - const user = await requireSessionUser(request); - - const userRoutes = await listRoutes(user.id); - return data({ - routes: userRoutes.map((r) => ({ - id: r.id, - name: r.name, - distance: r.distance, - elevationGain: r.elevationGain, - updatedAt: r.updatedAt.toISOString(), - geojson: r.geojson ?? null, - })), - }); + return data(await loadRoutesIndex(request)); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/routes.new.server.ts b/apps/journal/app/routes/routes.new.server.ts new file mode 100644 index 0000000..cf6f9ac --- /dev/null +++ b/apps/journal/app/routes/routes.new.server.ts @@ -0,0 +1,29 @@ +// Server-only loader/action for /routes/new. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { createRoute } from "~/lib/routes.server"; + +export async function loadRoutesNew(request: Request) { + await requireSessionUser(request); + return {}; +} + +export async function routesNewAction(request: Request) { + const user = await requireSessionUser(request); + + const formData = await request.formData(); + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + + if (!name) return data({ error: "Name is required" }, { status: 400 }); + + let gpx: string | undefined; + if (gpxFile && gpxFile.size > 0) { + gpx = await gpxFile.text(); + } + + const routeId = await createRoute(user.id, { name, description, gpx }); + return redirect(`/routes/${routeId}`); +} diff --git a/apps/journal/app/routes/routes.new.tsx b/apps/journal/app/routes/routes.new.tsx index c89f7e3..03f2446 100644 --- a/apps/journal/app/routes/routes.new.tsx +++ b/apps/journal/app/routes/routes.new.tsx @@ -1,31 +1,14 @@ -import { data, redirect } from "react-router"; +import { data } from "react-router"; import type { Route } from "./+types/routes.new"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { createRoute } from "~/lib/routes.server"; +import { loadRoutesNew, routesNewAction } from "./routes.new.server"; export async function loader({ request }: Route.LoaderArgs) { - await requireSessionUser(request); - return data({}); + return data(await loadRoutesNew(request)); } export async function action({ request }: Route.ActionArgs) { - const user = await requireSessionUser(request); - - const formData = await request.formData(); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - const gpxFile = formData.get("gpx") as File | null; - - if (!name) return data({ error: "Name is required" }, { status: 400 }); - - let gpx: string | undefined; - if (gpxFile && gpxFile.size > 0) { - gpx = await gpxFile.text(); - } - - const routeId = await createRoute(user.id, { name, description, gpx }); - return redirect(`/routes/${routeId}`); + return await routesNewAction(request); } export function meta(_args: Route.MetaArgs) { diff --git a/apps/journal/app/routes/settings.account.server.ts b/apps/journal/app/routes/settings.account.server.ts new file mode 100644 index 0000000..af253b7 --- /dev/null +++ b/apps/journal/app/routes/settings.account.server.ts @@ -0,0 +1,15 @@ +// Server-only loader for /settings/account. See `home.server.ts`. + +import { redirect } from "react-router"; +import { getSessionUser } from "~/lib/auth/session.server"; + +export async function loadAccountSettings(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return { + user: { + username: user.username, + email: user.email, + }, + }; +} diff --git a/apps/journal/app/routes/settings.account.tsx b/apps/journal/app/routes/settings.account.tsx index 0dc41c1..f48cade 100644 --- a/apps/journal/app/routes/settings.account.tsx +++ b/apps/journal/app/routes/settings.account.tsx @@ -1,22 +1,15 @@ import { useState, useEffect } from "react"; -import { data, redirect, useFetcher } from "react-router"; +import { data, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings.account"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { loadAccountSettings } from "./settings.account.server"; export function meta() { return [{ title: "Account — Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - return data({ - user: { - username: user.username, - email: user.email, - }, - }); + return data(await loadAccountSettings(request)); } export default function AccountSettings({ loaderData }: Route.ComponentProps) { diff --git a/apps/journal/app/routes/settings.connections.komoot.server.ts b/apps/journal/app/routes/settings.connections.komoot.server.ts new file mode 100644 index 0000000..ea9adb5 --- /dev/null +++ b/apps/journal/app/routes/settings.connections.komoot.server.ts @@ -0,0 +1,23 @@ +// Server-only loader for /settings/connections/komoot. See `home.server.ts`. + +import { redirect } from "react-router"; +import { getOrigin } from "~/lib/config.server"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { getService } from "~/lib/connected-services/manager"; + +export async function loadKomootConnection(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const service = await getService(user.id, "komoot"); + const origin = getOrigin(); + const trailsProfileUrl = `${origin}/users/${user.username}`; + + return { + connected: !!service, + mode: service ? (service.credentials as { mode?: string }).mode ?? null : null, + providerUserId: service?.providerUserId ?? null, + serviceId: service?.id ?? null, + trailsProfileUrl, + }; +} diff --git a/apps/journal/app/routes/settings.connections.komoot.tsx b/apps/journal/app/routes/settings.connections.komoot.tsx index 3967f3b..7c48941 100644 --- a/apps/journal/app/routes/settings.connections.komoot.tsx +++ b/apps/journal/app/routes/settings.connections.komoot.tsx @@ -3,32 +3,17 @@ // Authenticated — email + password (password encrypted at rest) import { useState } from "react"; -import { data, redirect, useFetcher } from "react-router"; -import { getOrigin } from "~/lib/config.server"; +import { data, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings.connections.komoot"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { getService } from "~/lib/connected-services/manager"; +import { loadKomootConnection } from "./settings.connections.komoot.server"; export function meta() { return [{ title: "Connect Komoot — Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - - const service = await getService(user.id, "komoot"); - const origin = getOrigin(); - const trailsProfileUrl = `${origin}/users/${user.username}`; - - return data({ - connected: !!service, - mode: service ? (service.credentials as { mode?: string }).mode ?? null : null, - providerUserId: service?.providerUserId ?? null, - serviceId: service?.id ?? null, - trailsProfileUrl, - }); + return data(await loadKomootConnection(request)); } type VerifyResponse = { success?: boolean; error?: string }; diff --git a/apps/journal/app/routes/settings.profile.server.ts b/apps/journal/app/routes/settings.profile.server.ts new file mode 100644 index 0000000..62865a2 --- /dev/null +++ b/apps/journal/app/routes/settings.profile.server.ts @@ -0,0 +1,17 @@ +// Server-only loader for /settings/profile. See `home.server.ts`. + +import { redirect } from "react-router"; +import { getSessionUser } from "~/lib/auth/session.server"; + +export async function loadProfileSettings(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return { + user: { + username: user.username, + displayName: user.displayName, + bio: user.bio, + profileVisibility: user.profileVisibility, + }, + }; +} diff --git a/apps/journal/app/routes/settings.profile.tsx b/apps/journal/app/routes/settings.profile.tsx index 8f8ad5c..3dda3d6 100644 --- a/apps/journal/app/routes/settings.profile.tsx +++ b/apps/journal/app/routes/settings.profile.tsx @@ -1,24 +1,15 @@ import { useState, useEffect } from "react"; -import { data, redirect, useFetcher } from "react-router"; +import { data, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings.profile"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { loadProfileSettings } from "./settings.profile.server"; export function meta() { return [{ title: "Profile — Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - return data({ - user: { - username: user.username, - displayName: user.displayName, - bio: user.bio, - profileVisibility: user.profileVisibility, - }, - }); + return data(await loadProfileSettings(request)); } export default function ProfileSettings({ loaderData }: Route.ComponentProps) { diff --git a/apps/journal/app/routes/settings.security.server.ts b/apps/journal/app/routes/settings.security.server.ts new file mode 100644 index 0000000..1853494 --- /dev/null +++ b/apps/journal/app/routes/settings.security.server.ts @@ -0,0 +1,33 @@ +// Server-only loader for /settings/security. See `home.server.ts`. + +import { redirect } from "react-router"; +import { eq } from "drizzle-orm"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { getDb } from "~/lib/db"; +import { credentials } from "@trails-cool/db/schema/journal"; + +export async function loadSecuritySettings(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const db = getDb(); + const passkeys = await db + .select({ + id: credentials.id, + deviceType: credentials.deviceType, + transports: credentials.transports, + createdAt: credentials.createdAt, + }) + .from(credentials) + .where(eq(credentials.userId, user.id)); + + return { + userId: user.id, + passkeys: passkeys.map((p) => ({ + id: p.id, + deviceType: p.deviceType, + transports: p.transports as string[] | null, + createdAt: p.createdAt.toISOString(), + })), + }; +} diff --git a/apps/journal/app/routes/settings.security.tsx b/apps/journal/app/routes/settings.security.tsx index 0062ca4..e98ab78 100644 --- a/apps/journal/app/routes/settings.security.tsx +++ b/apps/journal/app/routes/settings.security.tsx @@ -1,41 +1,16 @@ import { useState, useEffect, useCallback } from "react"; -import { data, redirect, useFetcher } from "react-router"; +import { data, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; -import { eq } from "drizzle-orm"; import type { Route } from "./+types/settings.security"; -import { getSessionUser } from "~/lib/auth/session.server"; -import { getDb } from "~/lib/db"; -import { credentials } from "@trails-cool/db/schema/journal"; import { ClientDate } from "~/components/ClientDate"; +import { loadSecuritySettings } from "./settings.security.server"; export function meta() { return [{ title: "Security — Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - - const db = getDb(); - const passkeys = await db - .select({ - id: credentials.id, - deviceType: credentials.deviceType, - transports: credentials.transports, - createdAt: credentials.createdAt, - }) - .from(credentials) - .where(eq(credentials.userId, user.id)); - - return data({ - userId: user.id, - passkeys: passkeys.map((p) => ({ - id: p.id, - deviceType: p.deviceType, - transports: p.transports as string[] | null, - createdAt: p.createdAt.toISOString(), - })), - }); + return data(await loadSecuritySettings(request)); } function transportLabel(transports: string[] | null, t: (key: string) => string): string { diff --git a/apps/journal/app/routes/settings.server.ts b/apps/journal/app/routes/settings.server.ts new file mode 100644 index 0000000..62e87c3 --- /dev/null +++ b/apps/journal/app/routes/settings.server.ts @@ -0,0 +1,10 @@ +// Server-only loader for /settings layout. See `home.server.ts`. + +import { redirect } from "react-router"; +import { getSessionUser } from "~/lib/auth/session.server"; + +export async function loadSettingsLayout(request: Request) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return { username: user.username }; +} diff --git a/apps/journal/app/routes/settings.tsx b/apps/journal/app/routes/settings.tsx index a85c0e4..f7dec21 100644 --- a/apps/journal/app/routes/settings.tsx +++ b/apps/journal/app/routes/settings.tsx @@ -1,17 +1,15 @@ -import { Outlet, redirect } from "react-router"; +import { Outlet } from "react-router"; import { Link, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings"; -import { getSessionUser } from "~/lib/auth/session.server"; +import { loadSettingsLayout } from "./settings.server"; export function meta() { return [{ title: "Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - const user = await getSessionUser(request); - if (!user) throw redirect("/auth/login"); - return { username: user.username }; + return await loadSettingsLayout(request); } interface NavItem { diff --git a/apps/journal/app/routes/sync.import.$provider.server.ts b/apps/journal/app/routes/sync.import.$provider.server.ts new file mode 100644 index 0000000..ba4a6a9 --- /dev/null +++ b/apps/journal/app/routes/sync.import.$provider.server.ts @@ -0,0 +1,118 @@ +// Server-only loader/action for /sync/import/:provider. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { + getManifest, + getService, + capabilityContextFor, +} from "~/lib/connected-services"; +import { getImportedIds, recordImport } from "~/lib/sync/imports.server"; +import { createActivity } from "~/lib/activities.server"; + +export async function loadSyncImportProvider(request: Request, provider: string | undefined) { + const user = await requireSessionUser(request); + + const manifest = getManifest(provider ?? ""); + if (!manifest || !manifest.importer) { + throw data({ error: "Unknown provider" }, { status: 404 }); + } + + const service = await getService(user.id, manifest.id); + if (!service) throw redirect("/settings"); + + const url = new URL(request.url); + const page = parseInt(url.searchParams.get("page") ?? "1"); + + const ctx = capabilityContextFor(service.id); + const workoutList = await manifest.importer.listImportable(ctx, page); + + const importedIds = await getImportedIds( + user.id, + manifest.id, + workoutList.workouts.map((w) => w.id), + ); + + return { + provider: { id: manifest.id, name: manifest.displayName }, + workouts: workoutList.workouts.map((w) => ({ + ...w, + imported: importedIds.has(w.id), + })), + page: workoutList.page, + totalPages: Math.ceil(workoutList.total / workoutList.perPage), + }; +} + +export async function syncImportProviderAction(request: Request, provider: string | undefined) { + const user = await requireSessionUser(request); + + const manifest = getManifest(provider ?? ""); + if (!manifest || !manifest.importer) { + throw data({ error: "Unknown provider" }, { status: 404 }); + } + + const service = await getService(user.id, manifest.id); + if (!service) return redirect("/settings"); + + const formData = await request.formData(); + const workoutId = formData.get("workoutId") as string; + const workoutName = formData.get("workoutName") as string; + const fileUrl = formData.get("fileUrl") as string; + const startedAt = formData.get("startedAt") as string; + const distance = formData.get("distance") as string; + const duration = formData.get("duration") as string; + + // Inline import here (not via Importer.importOne) so we can pass through + // the form-supplied metadata (started_at, distance, duration) the + // capability seam doesn't carry. The seam-shape importer is reserved for + // automatic / webhook-driven imports. + let gpx: string | null = null; + if (fileUrl) { + const ctx = capabilityContextFor(service.id); + // Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials + // so the manager handles refresh of any near-expired token. + const buffer = await ctx.withFreshCredentials(async () => { + const resp = await fetch(fileUrl); + if (!resp.ok) throw new Error(`Download failed: ${resp.status}`); + return Buffer.from(await resp.arrayBuffer()); + }); + // Lazy-load to avoid bundling fit-file-parser into all routes. + const { default: FitParser } = await import("fit-file-parser"); + const { generateGpx } = await import("@trails-cool/gpx"); + const parsed = await new Promise>((resolve, reject) => { + const parser = new FitParser({ force: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parser.parse(buffer as any, (err: unknown, d: any) => (err ? reject(err) : resolve(d ?? {}))); + }); + const records = (parsed.records ?? []) as Array<{ + position_lat?: number; + position_long?: number; + altitude?: number; + timestamp?: string | Date; + }>; + const trackPoints = records + .filter((r) => r.position_lat != null && r.position_long != null) + .map((r) => ({ + lat: r.position_lat!, + lon: r.position_long!, + ele: r.altitude, + time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, + })); + if (trackPoints.length >= 2) { + gpx = generateGpx({ name: workoutName, tracks: [trackPoints] }); + } + } + + const activityId = await createActivity(user.id, { + name: workoutName || `${manifest.displayName} workout`, + gpx: gpx ?? undefined, + distance: distance ? parseFloat(distance) : null, + duration: duration ? parseInt(duration) : null, + startedAt: startedAt ? new Date(startedAt) : null, + }); + + await recordImport(user.id, manifest.id, workoutId, activityId); + + return { imported: workoutId }; +} diff --git a/apps/journal/app/routes/sync.import.$provider.tsx b/apps/journal/app/routes/sync.import.$provider.tsx index 99bd41f..37e6832 100644 --- a/apps/journal/app/routes/sync.import.$provider.tsx +++ b/apps/journal/app/routes/sync.import.$provider.tsx @@ -1,122 +1,16 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { data, redirect, useFetcher } from "react-router"; +import { data, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/sync.import.$provider"; -import { requireSessionUser } from "~/lib/auth/session.server"; -import { - getManifest, - getService, - capabilityContextFor, -} from "~/lib/connected-services"; -import { getImportedIds, recordImport } from "~/lib/sync/imports.server"; -import { createActivity } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; +import { loadSyncImportProvider, syncImportProviderAction } from "./sync.import.$provider.server"; export async function loader({ params, request }: Route.LoaderArgs) { - const user = await requireSessionUser(request); - - const manifest = getManifest(params.provider); - if (!manifest || !manifest.importer) { - throw data({ error: "Unknown provider" }, { status: 404 }); - } - - const service = await getService(user.id, manifest.id); - if (!service) return redirect("/settings"); - - const url = new URL(request.url); - const page = parseInt(url.searchParams.get("page") ?? "1"); - - const ctx = capabilityContextFor(service.id); - const workoutList = await manifest.importer.listImportable(ctx, page); - - const importedIds = await getImportedIds( - user.id, - manifest.id, - workoutList.workouts.map((w) => w.id), - ); - - return data({ - provider: { id: manifest.id, name: manifest.displayName }, - workouts: workoutList.workouts.map((w) => ({ - ...w, - imported: importedIds.has(w.id), - })), - page: workoutList.page, - totalPages: Math.ceil(workoutList.total / workoutList.perPage), - }); + return data(await loadSyncImportProvider(request, params.provider)); } export async function action({ params, request }: Route.ActionArgs) { - const user = await requireSessionUser(request); - - const manifest = getManifest(params.provider); - if (!manifest || !manifest.importer) { - throw data({ error: "Unknown provider" }, { status: 404 }); - } - - const service = await getService(user.id, manifest.id); - if (!service) return redirect("/settings"); - - const formData = await request.formData(); - const workoutId = formData.get("workoutId") as string; - const workoutName = formData.get("workoutName") as string; - const fileUrl = formData.get("fileUrl") as string; - const startedAt = formData.get("startedAt") as string; - const distance = formData.get("distance") as string; - const duration = formData.get("duration") as string; - - // Inline import here (not via Importer.importOne) so we can pass through - // the form-supplied metadata (started_at, distance, duration) the - // capability seam doesn't carry. The seam-shape importer is reserved for - // automatic / webhook-driven imports. - let gpx: string | null = null; - if (fileUrl) { - const ctx = capabilityContextFor(service.id); - // Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials - // so the manager handles refresh of any near-expired token. - const buffer = await ctx.withFreshCredentials(async () => { - const resp = await fetch(fileUrl); - if (!resp.ok) throw new Error(`Download failed: ${resp.status}`); - return Buffer.from(await resp.arrayBuffer()); - }); - // Lazy-load to avoid bundling fit-file-parser into all routes. - const { default: FitParser } = await import("fit-file-parser"); - const { generateGpx } = await import("@trails-cool/gpx"); - const parsed = await new Promise>((resolve, reject) => { - const parser = new FitParser({ force: true }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parser.parse(buffer as any, (err: unknown, d: any) => (err ? reject(err) : resolve(d ?? {}))); - }); - const records = (parsed.records ?? []) as Array<{ - position_lat?: number; - position_long?: number; - altitude?: number; - timestamp?: string | Date; - }>; - const trackPoints = records - .filter((r) => r.position_lat != null && r.position_long != null) - .map((r) => ({ - lat: r.position_lat!, - lon: r.position_long!, - ele: r.altitude, - time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, - })); - if (trackPoints.length >= 2) { - gpx = generateGpx({ name: workoutName, tracks: [trackPoints] }); - } - } - - const activityId = await createActivity(user.id, { - name: workoutName || `${manifest.displayName} workout`, - gpx: gpx ?? undefined, - distance: distance ? parseFloat(distance) : null, - duration: duration ? parseInt(duration) : null, - startedAt: startedAt ? new Date(startedAt) : null, - }); - - await recordImport(user.id, manifest.id, workoutId, activityId); - - return data({ imported: workoutId }); + return data(await syncImportProviderAction(request, params.provider)); } export function meta({ data: loaderData }: Route.MetaArgs) { diff --git a/apps/journal/app/routes/sync.import.komoot.server.ts b/apps/journal/app/routes/sync.import.komoot.server.ts new file mode 100644 index 0000000..aedc307 --- /dev/null +++ b/apps/journal/app/routes/sync.import.komoot.server.ts @@ -0,0 +1,54 @@ +// Server-only loader/action for /sync/import/komoot. See `home.server.ts`. + +import { data, redirect } from "react-router"; +import { desc, eq, and } from "drizzle-orm"; +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"; + +export async function loadKomootImport(request: Request) { + const user = await requireSessionUser(request); + + const service = await getService(user.id, "komoot"); + if (!service) throw redirect("/settings/connections/komoot"); + + const db = getDb(); + const [batch] = await db + .select() + .from(importBatches) + .where(and(eq(importBatches.userId, user.id), eq(importBatches.connectionId, service.id))) + .orderBy(desc(importBatches.startedAt)) + .limit(1); + + return { + batch: batch + ? { + id: batch.id, + status: batch.status, + totalFound: batch.totalFound, + importedCount: batch.importedCount, + duplicateCount: batch.duplicateCount, + errorMessage: batch.errorMessage, + startedAt: batch.startedAt.toISOString(), + completedAt: batch.completedAt?.toISOString() ?? null, + } + : null, + }; +} + +export async function komootImportAction(request: Request) { + await requireSessionUser(request); + + // Delegate to the API route — just redirect so the page reloads with + // the new batch after the POST. + const resp = await fetch( + new URL("/api/sync/komoot/import", new URL(request.url).origin), + { method: "POST", headers: { cookie: request.headers.get("cookie") ?? "" } }, + ); + if (!resp.ok) { + const body = (await resp.json()) as { error?: string }; + return data({ error: body.error ?? "failed" }, { status: resp.status }); + } + return redirect("/sync/import/komoot"); +} diff --git a/apps/journal/app/routes/sync.import.komoot.tsx b/apps/journal/app/routes/sync.import.komoot.tsx index e74efbe..290d35a 100644 --- a/apps/journal/app/routes/sync.import.komoot.tsx +++ b/apps/journal/app/routes/sync.import.komoot.tsx @@ -2,63 +2,21 @@ // and lets the user trigger a new import run. import { useEffect, useRef } from "react"; -import { data, redirect, useFetcher, useRevalidator } from "react-router"; +import { data, useFetcher, useRevalidator } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/sync.import.komoot"; -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"; -import { desc, eq, and } from "drizzle-orm"; +import { loadKomootImport, komootImportAction } from "./sync.import.komoot.server"; export function meta() { return [{ title: "Import from Komoot — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { - const user = await requireSessionUser(request); - - const service = await getService(user.id, "komoot"); - if (!service) return redirect("/settings/connections/komoot"); - - const db = getDb(); - const [batch] = await db - .select() - .from(importBatches) - .where(and(eq(importBatches.userId, user.id), eq(importBatches.connectionId, service.id))) - .orderBy(desc(importBatches.startedAt)) - .limit(1); - - return data({ - batch: batch - ? { - id: batch.id, - status: batch.status, - totalFound: batch.totalFound, - importedCount: batch.importedCount, - duplicateCount: batch.duplicateCount, - errorMessage: batch.errorMessage, - startedAt: batch.startedAt.toISOString(), - completedAt: batch.completedAt?.toISOString() ?? null, - } - : null, - }); + return data(await loadKomootImport(request)); } export async function action({ request }: Route.ActionArgs) { - await requireSessionUser(request); - - // Delegate to the API route — just redirect so the page reloads with - // the new batch after the POST. - const resp = await fetch( - new URL("/api/sync/komoot/import", new URL(request.url).origin), - { method: "POST", headers: { cookie: request.headers.get("cookie") ?? "" } }, - ); - if (!resp.ok) { - const body = (await resp.json()) as { error?: string }; - return data({ error: body.error ?? "failed" }, { status: resp.status }); - } - return redirect("/sync/import/komoot"); + return await komootImportAction(request); } function formatDuration(seconds: number): string { diff --git a/apps/journal/app/routes/users.$username.followers.server.ts b/apps/journal/app/routes/users.$username.followers.server.ts new file mode 100644 index 0000000..4190412 --- /dev/null +++ b/apps/journal/app/routes/users.$username.followers.server.ts @@ -0,0 +1,46 @@ +// Server-only loader for /users/:username/followers. See `home.server.ts`. + +import { data } from "react-router"; +import { eq } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { users } from "@trails-cool/db/schema/journal"; +import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server"; +import { getSessionUser } from "~/lib/auth/session.server"; + +export async function loadUserFollowers(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 }); + } + + // Locked-account model: only the owner and accepted followers can see + // a private user's followers list. Non-followers (anonymous or signed-in) + // get the same 404 a stranger sees, mirroring the profile-route policy. + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + const followState = !isOwn && currentUser + ? await getFollowState(currentUser.id, user.username) + : null; + const canSee = + isOwn || + user.profileVisibility === "public" || + (followState !== null && followState.following === true); + if (!canSee) { + throw data({ error: "User not found" }, { status: 404 }); + } + + const url = new URL(request.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1); + const [entries, total] = await Promise.all([ + listFollowers(user.id, page), + countFollowers(user.id), + ]); + + return { + user: { username: user.username, displayName: user.displayName }, + page, + total, + entries, + }; +} diff --git a/apps/journal/app/routes/users.$username.followers.tsx b/apps/journal/app/routes/users.$username.followers.tsx index 9b22fcd..c1058ff 100644 --- a/apps/journal/app/routes/users.$username.followers.tsx +++ b/apps/journal/app/routes/users.$username.followers.tsx @@ -1,48 +1,10 @@ import { data } from "react-router"; -import { eq } from "drizzle-orm"; import type { Route } from "./+types/users.$username.followers"; -import { getDb } from "~/lib/db"; -import { users } from "@trails-cool/db/schema/journal"; -import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server"; -import { getSessionUser } from "~/lib/auth/session.server"; import { CollectionPage } from "~/components/CollectionPage"; +import { loadUserFollowers } from "./users.$username.followers.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 }); - } - - // Locked-account model: only the owner and accepted followers can see - // a private user's followers list. Non-followers (anonymous or signed-in) - // get the same 404 a stranger sees, mirroring the profile-route policy. - const currentUser = await getSessionUser(request); - const isOwn = currentUser?.id === user.id; - const followState = !isOwn && currentUser - ? await getFollowState(currentUser.id, user.username) - : null; - const canSee = - isOwn || - user.profileVisibility === "public" || - (followState !== null && followState.following === true); - if (!canSee) { - throw data({ error: "User not found" }, { status: 404 }); - } - - const url = new URL(request.url); - const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1); - const [entries, total] = await Promise.all([ - listFollowers(user.id, page), - countFollowers(user.id), - ]); - - return data({ - user: { username: user.username, displayName: user.displayName }, - page, - total, - entries, - }); + return data(await loadUserFollowers(request, params.username)); } export function meta({ data: d }: Route.MetaArgs) { diff --git a/apps/journal/app/routes/users.$username.following.server.ts b/apps/journal/app/routes/users.$username.following.server.ts new file mode 100644 index 0000000..98a87a8 --- /dev/null +++ b/apps/journal/app/routes/users.$username.following.server.ts @@ -0,0 +1,45 @@ +// Server-only loader for /users/:username/following. See `home.server.ts`. + +import { data } from "react-router"; +import { eq } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { users } from "@trails-cool/db/schema/journal"; +import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server"; +import { getSessionUser } from "~/lib/auth/session.server"; + +export async function loadUserFollowing(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 }); + } + + // Locked-account model — see users.$username.followers.tsx for the + // policy. Same canSee rule applies to the following list. + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + const followState = !isOwn && currentUser + ? await getFollowState(currentUser.id, user.username) + : null; + const canSee = + isOwn || + user.profileVisibility === "public" || + (followState !== null && followState.following === true); + if (!canSee) { + throw data({ error: "User not found" }, { status: 404 }); + } + + const url = new URL(request.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1); + const [entries, total] = await Promise.all([ + listFollowing(user.id, page), + countFollowing(user.id), + ]); + + return { + user: { username: user.username, displayName: user.displayName }, + page, + total, + entries, + }; +} diff --git a/apps/journal/app/routes/users.$username.following.tsx b/apps/journal/app/routes/users.$username.following.tsx index 1c8237f..a36eb2f 100644 --- a/apps/journal/app/routes/users.$username.following.tsx +++ b/apps/journal/app/routes/users.$username.following.tsx @@ -1,47 +1,10 @@ import { data } from "react-router"; -import { eq } from "drizzle-orm"; import type { Route } from "./+types/users.$username.following"; -import { getDb } from "~/lib/db"; -import { users } from "@trails-cool/db/schema/journal"; -import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server"; -import { getSessionUser } from "~/lib/auth/session.server"; import { CollectionPage } from "~/components/CollectionPage"; +import { loadUserFollowing } from "./users.$username.following.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 }); - } - - // Locked-account model — see users.$username.followers.tsx for the - // policy. Same canSee rule applies to the following list. - const currentUser = await getSessionUser(request); - const isOwn = currentUser?.id === user.id; - const followState = !isOwn && currentUser - ? await getFollowState(currentUser.id, user.username) - : null; - const canSee = - isOwn || - user.profileVisibility === "public" || - (followState !== null && followState.following === true); - if (!canSee) { - throw data({ error: "User not found" }, { status: 404 }); - } - - const url = new URL(request.url); - const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1); - const [entries, total] = await Promise.all([ - listFollowing(user.id, page), - countFollowing(user.id), - ]); - - return data({ - user: { username: user.username, displayName: user.displayName }, - page, - total, - entries, - }); + return data(await loadUserFollowing(request, params.username)); } export function meta({ data: d }: Route.MetaArgs) {