// 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 { requireOwnedActivity, requireOwnedRoute } from "~/lib/ownership.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 fetched = await getActivity(id ?? ""); if (!fetched) throw data({ error: "Activity not found" }, { status: 404 }); // Remote-ingested activities have no local detail page — their // canonical page lives on the origin instance; the feed links there. if (fetched.ownerId === null) throw data({ error: "Activity not found" }, { status: 404 }); const activity = { ...fetched, ownerId: fetched.ownerId }; 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 activity = await requireOwnedActivity(activityId, user.id); const routeIdRaw = formData.get("routeId") as string; if (routeIdRaw) { const route = await requireOwnedRoute(routeIdRaw, user.id); await linkActivityToRoute(activity, route); } return redirect(`/activities/${activityId}`); } if (intent === "create-route") { const activity = await requireOwnedActivity(activityId, user.id); const routeId = await createRouteFromActivity(activity); if (routeId) return redirect(`/routes/${routeId}`); return data({ error: "No GPX data to create route from" }, { status: 400 }); } if (intent === "delete") { const activity = await requireOwnedActivity(activityId, user.id); await deleteImportByActivity(activityId); await deleteActivity(activity); return redirect("/activities"); } 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 activity = await requireOwnedActivity(activityId, user.id); await updateActivityVisibility(activity, raw as Visibility); return redirect(`/activities/${activityId}`); } return data({ error: "Unknown action" }, { status: 400 }); }