Files
frontend/src/features/tbank-connection/model/screen-sync.ts
T
ksv741 d20be86d73
Frontend verify / verify (pull_request) Canceled after 0s
Frontend security / security (pull_request) Canceled after 0s
Frontend security / security (push) Canceled after 0s
Frontend verify / verify (push) Canceled after 0s
Conventional commits / commits (pull_request) Canceled after 0s
refactor: enforce enum machine codes #18
Use owner enums for known codes; preserve wire values and unknown fallbacks.

Verified npm ci and verify: 379 tests, 5 lint checks, app and Storybook builds.
2026-09-28 13:15:27 +03:00

197 lines
6.4 KiB
TypeScript

import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
ApiErrorCode,
apiErrorCode,
apiQueryClient,
invalidateResourceAccess,
isApiErrorCode,
type paths,
useAuthenticatedQueries,
} from "@/shared/api";
import { type InvestmentScreen, useTbankSync } from "./sync";
type ResourceMeta
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type ScreenResource = {
meta?: ResourceMeta;
observe: (version?: string) => Promise<unknown>;
queryKey: QueryKey;
transport: {
fetchStatus: "fetching" | "idle" | "paused";
status: "error" | "pending" | "success";
};
};
const STATUS_INTERVAL_MS = 1000;
function observeResource(
resource: ScreenResource,
latest: Map<string, ResourceMeta>,
completedId: string | undefined,
observed: Map<string, string>,
) {
if (!resource.meta) {
if (!completedId || resource.transport.fetchStatus !== "idle") return;
const key = `query:${JSON.stringify(resource.queryKey)}`;
if (observed.get(key) === completedId) return;
observed.set(key, completedId);
void resource.observe().catch(() => {});
return;
}
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
].flatMap((current) => {
const notified = latest.get(current.resourceKey);
return notified && notified.stateVersion !== current.stateVersion
? [notified]
: [];
});
if (changed.length === 0) return;
const notification = JSON.stringify(
changed.map(meta => [meta.resourceKey, meta.stateVersion]),
);
if (observed.get(resource.meta.resourceKey) === notification)
return;
observed.set(resource.meta.resourceKey, notification);
const own = changed.find(
meta => meta.resourceKey === resource.meta?.resourceKey,
);
void resource.observe(own?.stateVersion).catch(() => {});
}
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = useTbankSync();
const client = useQueryClient();
const deniedRef = useRef(new Set<unknown>());
const scopeKey = JSON.stringify(scope);
const [requested, setRequested] = useState<null | {
id: string;
scope: string;
}>(null);
const requestInFlightRef = useRef<null | string>(null);
const currentScopeRef = useRef(scopeKey);
useLayoutEffect(() => {
currentScopeRef.current = scopeKey;
}, [scopeKey]);
const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
const observedRef = useRef(new Map<string, string>());
const ids = [
...new Set([
...(requested?.scope === scopeKey ? [requested.id] : []),
...resources
.flatMap(({ meta }) =>
meta ? [meta, ...(meta.dependencies ?? [])] : [],
)
.flatMap(meta =>
meta?.sync.id
&& (meta.sync.state === "running" || meta.sync.state === "queued")
? [meta.sync.id]
: [],
),
]),
];
const statuses = useAuthenticatedQueries(
ids.map(id => ({
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
params: { path: { id } },
}),
refetchInterval: (query: {
state: { data?: { state: string }; error: unknown };
}) =>
!(query.state.error || (query.state.data && query.state.data.state !== "running")) && STATUS_INTERVAL_MS,
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
})),
);
useEffect(() => {
for (const status of statuses) {
if (!status.error || deniedRef.current.has(status.error)) {
continue;
}
deniedRef.current.add(status.error);
if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound))
invalidateResourceAccess(client, "brokerage", status.error);
}
const latest = new Map<string, ResourceMeta>();
const orderedStatuses = statuses.toSorted((a, b) => a.dataUpdatedAt - b.dataUpdatedAt);
for (const status of orderedStatuses) {
const statusResources = status.data?.resources ?? [];
for (const meta of statusResources) {
latest.set(meta.resourceKey, meta);
const dependencies = meta.dependencies ?? [];
for (const dependency of dependencies)
latest.set(dependency.resourceKey, dependency);
}
}
const completed = statuses.find(status => requested?.scope === scopeKey
&& status.data?.id === requested.id && status.data.state !== "running")?.data;
for (const resource of resources) observeResource(resource, latest, completed?.id, observedRef.current);
}, [client, statuses, resources, requested, scopeKey]);
const isRefreshing
= (isCurrentMutation && mutation.isPending)
|| statuses.some(
status => !status.isError && status.data?.state === "running",
)
|| resources.some(({ meta }) => {
const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
return (
!status?.isError
&& (!status?.data || status.data.state === "running")
&& (meta?.sync.state === "running" || meta?.sync.state === "queued")
);
});
const refresh = async () => {
if (isRefreshing || requestInFlightRef.current === scopeKey) return;
requestInFlightRef.current = scopeKey;
try {
const accepted = await mutation.mutateAsync({ body: scope });
if (
currentScopeRef.current === scopeKey
&& requestInFlightRef.current === scopeKey
)
setRequested({ id: accepted.id, scope: scopeKey });
}
catch {
/*
The mutation retains the transport error for presentation.
*/
}
finally {
if (requestInFlightRef.current === scopeKey) requestInFlightRef.current = /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
}
};
// Retry failed HTTP reads without asking the source to import again.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => {
const failed = resources.filter(
resource => resource.transport.status === "error",
);
const failedStatuses = statuses.filter(status => status.isError);
if (failed.length > 0 || failedStatuses.length > 0) {
await Promise.allSettled([
...failed.map(resource => resource.observe()),
...failedStatuses.map(status => status.refetch()),
]);
}
else await refresh();
};
return {
refresh,
refreshing: isRefreshing,
retry,
sourceError:
statuses.some(status => status.data?.state === "failed")
|| resources.some(({ meta }) => meta?.sync.state === "failed"),
transportError:
(isCurrentMutation && mutation.isError)
|| statuses.some(status => status.isError),
};
}
export { useScreenSync };