import hoistNonReactStatic from "hoist-non-react-statics";
import { forwardRef, useEffect, useId, useState, useCallback } from "@/import/react";
import { useMutation, useQueryClient } from "@/import/reactQuery";
import getStoreSyncHelper from "@/shared/helper/sync/store";
import CentralSyncHelper, { useAreCentralStoresInSyncWithServer, useAreThereCentralProblematicCommits } from "@/shared/helper/sync/central";
import { useToken } from "@/shared/helper/user/generic";
import useElectLeaderNode from "@/shared/hook/useElectLeaderNode";
import getStore from "@/shared/store/index";
import { GraphQLClient, gql } from "graphql-request";
import { isBrowser } from "@/shared/helper/general";
import getGenericFormHelper from "@/shared/helper/form/generic";
import * as Sentry from "@sentry/nextjs";
import ModalWithCloseButton from "../components/variant/modal/withCloseButton";
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || "Element";
}
const endpoint = process.env.NEXT_PUBLIC_GRAPHQL_URL;
const graphQLClientPOST = new GraphQLClient(endpoint, {
method: "POST",
jsonSerializer: {
parse: JSON.parse,
stringify: JSON.stringify,
},
});
const updateHistory = gql`
mutation StoreHistory($id: ID!, $coc: String!, $type: Type!, $changes: [[Int!]], $heads: [String!], $ttl: Int, $municipalityCode: String!) {
storeHistory(input: { id: $id, coc: $coc, type: $type, changes: $changes, heads: $heads, ttl: $ttl, municipalityCode: $municipalityCode }) {
changes
heads
status
coc
municipalityCode
ttl
category
}
}
`;
const createMeeting = gql`
mutation CreateMeeting($coc: ID!, $municipalityCode: String!, $changes: [[Int]], $heads: [String]) {
createMeeting(input: { coc: $coc, municipalityCode: $municipalityCode, changes: $changes, heads: $heads }) {
changes
heads
status
id
municipalityCode
}
}
`;
const createInvestigation = gql`
mutation CreateInvestigation($coc: ID!, $municipalityCode: String!, $changes: [[Int]], $heads: [String]) {
createInvestigation(input: { coc: $coc, municipalityCode: $municipalityCode, changes: $changes, heads: $heads }) {
changes
heads
status
id
municipalityCode
}
}
`;
const createTask = gql`
mutation CreateTask($coc: ID!, $municipalityCode: String!, $changes: [[Int]], $heads: [String], $category: TaskCategory!, $originatesFrom: Meeting) {
createTask(input: { coc: $coc, municipalityCode: $municipalityCode, changes: $changes, heads: $heads, category: $category, originatesFrom: $originatesFrom }) {
changes
heads
status
id
municipalityCode
}
}
`;
const updateQuestionnaireHistory = gql`
mutation StoreDeclarationHistory($id: ID!, $coc: String!, $municipalityCode: String!, $changes: [[Int!]], $heads: [String!]) {
storeDeclarationHistory(input: { id: $id, coc: $coc, municipalityCode: $municipalityCode, changes: $changes, heads: $heads }) {
changes
heads
status
}
}
`;
const updateQuestionnaireHistoryAndSign = gql`
mutation StoreAndSignDeclarationHistory($id: ID!, $coc: String!, $municipalityCode: String!, $changes: [[Int!]], $heads: [String!]) {
storeDeclarationHistory(input: { id: $id, coc: $coc, municipalityCode: $municipalityCode, changes: $changes, heads: $heads }) {
changes
heads
status
}
signDeclaration(input: { id: $id, coc: $coc, municipalityCode: $municipalityCode, heads: $heads }) {
status
signature
signedAt
}
}
`;
function StandaloneBackgroundSyncer(props) {
// const areAllStoresInSync = useAreCentralStoresInSyncWithServer();
const areThereProblematicCommits = useAreThereCentralProblematicCommits();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(areThereProblematicCommits);
}, [areThereProblematicCommits]);
const buttonCallback = useCallback(() => {
if (typeof window !== "undefined") {
window.location.reload(true);
}
}, []);
// Future TODO's:
// 1. stop exit page when still syncing (or ask)
// 2. global status of sync level indication
const queryClient = useQueryClient();
const token = useToken();
const hookId = useId(); // Unique consistent token according to React itself.
const isElected = useElectLeaderNode(hookId, Symbol.for("CENTRAL_STORE"), Symbol.for("ALL_CENTRAL_COMMITS"));
useEffect(() => {
if (token) {
graphQLClientPOST.setHeader("Authorization", token);
}
}, [token]);
const { mutate: updateStore } = useMutation(["updateStore"], {
mutationFn: async ({ changes, heads, municipalityCode, originatesFrom, category, coc, id, type, ttl, actions = [] }) => {
if (typeof id !== "symbol" && type !== "questionnaire") {
return graphQLClientPOST.request(updateHistory, {
id,
type,
changes,
heads,
ttl,
coc,
municipalityCode,
});
}
if (type === "meeting") {
return graphQLClientPOST.request(createMeeting, {
coc,
municipalityCode,
changes,
heads,
category,
});
}
if (type === "investigation") {
return graphQLClientPOST.request(createInvestigation, {
coc,
municipalityCode,
changes,
heads,
category,
});
}
if (type === "task") {
return graphQLClientPOST.request(createTask, {
coc,
municipalityCode,
changes,
heads,
category,
originatesFrom,
});
}
if (type === "questionnaire") {
return graphQLClientPOST.request(actions.includes("sign") ? updateQuestionnaireHistoryAndSign : updateQuestionnaireHistory, {
id,
coc,
municipalityCode,
changes,
heads,
});
}
},
onMutate: async ({ commitId, id, type }) => {
// Cancel any outgoing refetches
// Not that it's any big deal, but just a waste of bandwidth.
await queryClient.cancelQueries({
queryKey: ["getStoreHistory", type, id],
});
const StoreSyncHelper = getStoreSyncHelper(type, id, { createInstance: false })
if (StoreSyncHelper) {
StoreSyncHelper.updateCommitStatus(commitId, "SUBMITTING");
}
return { commitId, type, id, StoreSyncHelper };
},
onError: async (error, { municipalityCode, changes, heads, coc }, { commitId, StoreSyncHelper, id, type }) => {
if (StoreSyncHelper) {
StoreSyncHelper.updateCommitStatus(commitId, "ERROR");
}
let message = error.message;
if (Array.isArray(error.response?.errors)) {
message = error.response.errors.map(({ message }) => message).join(";");
}
Sentry.withScope(function (scope) {
scope.setTag("municipality", municipalityCode || process.env.NEXT_PUBLIC_MUNICIPALITY_CODE || sessionStorage.getItem("municipalityCode"));
scope.setContext("syncInfo", {
localCommitId: commitId,
type,
id,
changes,
heads,
coc,
municipalityCode,
})
// setUser could be used here to set "accountManager at Veenendaal" or something :) will be handy in the future
// group errors together based on the error message and resource type. Prevents spamming servers as well.
scope.setFingerprint([message, type]);
Sentry.captureException(error);
});
},
onSuccess: async ({ createTask, createMeeting, createInvestigation, storeHistory, storeDeclarationHistory, signDeclaration } = {}, { id, type, commitId }) => {
const StoreSyncHelper = getStoreSyncHelper(type, id);
const createdObj = createTask ?? createMeeting ?? createInvestigation;
let processObj = createdObj ?? storeHistory;
processObj = processObj ?? storeDeclarationHistory;
if (!processObj) {
// We always expect an object back, something happened....
StoreSyncHelper.updateCommitStatus(commitId, "ERROR");
// console.log("ERROR with commit id", id);
// TODO: send to sentry
return;
}
StoreSyncHelper.updateCommitStatus(commitId, "COMPLETED");
if (processObj?.heads) {
StoreSyncHelper.syncedHeaders$ = processObj.heads;
if (Array.isArray(processObj.changes) && processObj.changes.length > 0) {
void getStore(type, id).applyChanges(processObj.changes);
}
}
if (processObj?.ttl) {
// TODO: for now only support setting a ttl, not removing it later on
// void getStore(type, id).getStore().sendUpdateToStore({ type: "SET_TTL", value: processObj.ttl });
}
if (storeDeclarationHistory && signDeclaration) {
// Certainty for the questionnaire where both functions are called.
processObj = {
...storeDeclarationHistory,
...signDeclaration,
};
}
const { status, declaredAt, signedAt, signature, category } = processObj;
void getGenericFormHelper(type, id).updateInfo({ status, declaredAt, signedAt, signature, category });
},
});
useEffect(() => {
// Ensure that this specific function is only triggered once, no matter how many times the useFormStore hook is included within the app.
if (!isElected) {
return () => {};
}
const subscription = CentralSyncHelper.serverCommits$.subscribe(
({ commit: { heads, actions = [], changes }, id, metaData: { storeType, storeId, ...metaData } }) => {
if (
storeType === "questionnaire" &&
isBrowser &&
window.location.pathname === "/verklaring/verklaren" &&
getStore(storeType, storeId).getSnapshot("declarationState.signed.input.value", { defaultValueOnMiss: false })
) {
actions.push("sign");
}
updateStore({
...metaData,
id: storeId,
type: storeType,
changes,
heads,
actions,
commitId: id,
});
}
);
return () => {
subscription.unsubscribe();
};
}, [updateStore, isElected]);
if (areThereProblematicCommits) {
return (
<ModalWithCloseButton
title="Er is zojuist een fout opgetreden"
description="Mogelijk is de allerlaatste wijziging verloren gegaan. Herlaad de pagina om verder te gaan.
Deze fout is automatisch gemeld bij het BasisBeeld-team. Onze excuses voor het ongemak."
buttonText="Herlaad de pagina"
open={open}
setOpen={setOpen}
buttonAction={buttonCallback}
status="error"
/>
);
}
return null;
}
/**
* Add a modal showing "no internet connection" when the component is mounted and the user is not online
* @param Component
* @returns {function({readOnly: *, [p: string]: *}): *}
*/
function withCentralSyncStore(Component) {
function WithCentralSyncStore(props, ref) {
return (
<>
<StandaloneBackgroundSyncer />
<Component {...props} ref={ref} />
</>
);
}
hoistNonReactStatic(WithCentralSyncStore, Component);
WithCentralSyncStore.displayName = `WithCentralSyncStore(${getDisplayName(Component)})`;
return forwardRef(WithCentralSyncStore);
}
export default withCentralSyncStore;