trails/apps/journal/app/routes/notifications.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

111 lines
3.5 KiB
TypeScript

// 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<string, unknown> | 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<string>();
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,
};
}