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>
111 lines
3.5 KiB
TypeScript
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,
|
|
};
|
|
}
|