trails/apps/journal/app/routes/users.$username.followers.server.ts
Ullrich Schäfer df562742e1
fix(journal): extract loaders/actions for the remaining 21 mixed routes
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 <route>.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) <noreply@anthropic.com>
2026-05-24 11:05:40 +02:00

46 lines
1.6 KiB
TypeScript

// 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,
};
}