diff options
Diffstat (limited to 'packages/auditor-backoffice-ui/src/hooks')
23 files changed, 5987 insertions, 0 deletions
diff --git a/packages/auditor-backoffice-ui/src/hooks/async.ts b/packages/auditor-backoffice-ui/src/hooks/async.ts new file mode 100644 index 000000000..f22badc88 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/async.ts @@ -0,0 +1,77 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useState } from "preact/hooks"; + +export interface Options { + slowTolerance: number; +} + +export interface AsyncOperationApi<T> { + request: (...a: any) => void; + cancel: () => void; + data: T | undefined; + isSlow: boolean; + isLoading: boolean; + error: string | undefined; +} + +export function useAsync<T>( + fn?: (...args: any) => Promise<T>, + { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }, +): AsyncOperationApi<T> { + const [data, setData] = useState<T | undefined>(undefined); + const [isLoading, setLoading] = useState<boolean>(false); + const [error, setError] = useState<any>(undefined); + const [isSlow, setSlow] = useState(false); + + const request = async (...args: any) => { + if (!fn) return; + setLoading(true); + + const handler = setTimeout(() => { + setSlow(true); + }, tooLong); + + try { + const result = await fn(...args); + setData(result); + } catch (error) { + setError(error); + } + setLoading(false); + setSlow(false); + clearTimeout(handler); + }; + + function cancel(): void { + setLoading(false); + setSlow(false); + } + + return { + request, + cancel, + data, + isSlow, + isLoading, + error, + }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/backend.ts b/packages/auditor-backoffice-ui/src/hooks/backend.ts new file mode 100644 index 000000000..8d99546a8 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/backend.ts @@ -0,0 +1,477 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util"; +import { + ErrorType, + HttpError, + HttpResponse, + HttpResponseOk, + RequestError, + RequestOptions, + useApiContext, +} from "@gnu-taler/web-util/browser"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import { useSWRConfig } from "swr"; +import { useBackendContext } from "../context/backend.js"; +import { useInstanceContext } from "../context/instance.js"; +import { AccessToken, LoginToken, MerchantBackend, Timestamp } from "../declaration.js"; + + +export function useMatchMutate(): ( + re?: RegExp, + value?: unknown, +) => Promise<any> { + const { cache, mutate } = useSWRConfig(); + + if (!(cache instanceof Map)) { + throw new Error( + "matchMutate requires the cache provider to be a Map instance", + ); + } + + return function matchRegexMutate(re?: RegExp) { + return mutate((key) => { + // evict if no key or regex === all + if (!key || !re) return true + // match string + if (typeof key === 'string' && re.test(key)) return true + // record or object have the path at [0] + if (typeof key === 'object' && re.test(key[0])) return true + //key didn't match regex + return false + }, undefined, { + revalidate: true, + }); + }; +} + +export function useBackendInstancesTestForAdmin(): HttpResponse< + MerchantBackend.Instances.InstancesResponse, + MerchantBackend.ErrorDetail +> { + const { request } = useBackendBaseRequest(); + + type Type = MerchantBackend.Instances.InstancesResponse; + + const [result, setResult] = useState< + HttpResponse<Type, MerchantBackend.ErrorDetail> + >({ loading: true }); + + useEffect(() => { + request<Type>(`/management/instances`) + .then((data) => setResult(data)) + .catch((error: RequestError<MerchantBackend.ErrorDetail>) => + setResult(error.cause), + ); + }, [request]); + + return result; +} + +const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000; +const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000; + +export function useBackendConfig(): HttpResponse< + MerchantBackend.VersionResponse | undefined, + RequestError<MerchantBackend.ErrorDetail> +> { + const { request } = useBackendBaseRequest(); + + type Type = MerchantBackend.VersionResponse; + type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number } + const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 }); + + useEffect(() => { + if (result.timer) { + clearTimeout(result.timer) + } + function tryConfig(): void { + request<Type>(`/config`) + .then((data) => { + const timer: any = setTimeout(() => { + tryConfig() + }, CHECK_CONFIG_INTERVAL_OK) + setResult({ data, timer }) + }) + .catch((error) => { + const timer: any = setTimeout(() => { + tryConfig() + }, CHECK_CONFIG_INTERVAL_FAIL) + const data = error.cause + setResult({ data, timer }) + }); + } + tryConfig() + }, [request]); + + return result.data; +} + +interface useBackendInstanceRequestType { + request: <T>( + endpoint: string, + options?: RequestOptions, + ) => Promise<HttpResponseOk<T>>; + fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; + reserveDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; + rewardsDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; + multiFetcher: <T>(params: [url: string[]]) => Promise<HttpResponseOk<T>[]>; + orderFetcher: <T>( + params: [endpoint: string, + paid?: YesOrNo, + refunded?: YesOrNo, + wired?: YesOrNo, + searchDate?: Date, + delta?: number,] + ) => Promise<HttpResponseOk<T>>; + transferFetcher: <T>( + params: [endpoint: string, + payto_uri?: string, + verified?: string, + position?: string, + delta?: number,] + ) => Promise<HttpResponseOk<T>>; + templateFetcher: <T>( + params: [endpoint: string, + position?: string, + delta?: number] + ) => Promise<HttpResponseOk<T>>; + webhookFetcher: <T>( + params: [endpoint: string, + position?: string, + delta?: number] + ) => Promise<HttpResponseOk<T>>; +} +interface useBackendBaseRequestType { + request: <T>( + endpoint: string, + options?: RequestOptions, + ) => Promise<HttpResponseOk<T>>; +} + +type YesOrNo = "yes" | "no"; +type LoginResult = { + valid: true; + token: string; + expiration: Timestamp; +} | { + valid: false; + cause: HttpError<{}>; +} + +export function useCredentialsChecker() { + const { request } = useApiContext(); + //check against instance details endpoint + //while merchant backend doesn't have a login endpoint + async function requestNewLoginToken( + baseUrl: string, + token: AccessToken, + ): Promise<LoginResult> { + const data: MerchantBackend.Instances.LoginTokenRequest = { + scope: "write", + duration: { + d_us: "forever" + }, + refreshable: true, + } + try { + const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, { + method: "POST", + token, + data + }); + return { valid: true, token: response.data.token, expiration: response.data.expiration }; + } catch (error) { + if (error instanceof RequestError) { + return { valid: false, cause: error.cause }; + } + + return { + valid: false, cause: { + type: ErrorType.UNEXPECTED, + loading: false, + info: { + hasToken: true, + status: 0, + options: {}, + url: `/private/token`, + payload: {} + }, + exception: error, + message: (error instanceof Error ? error.message : "unpexepected error") + } + }; + } + }; + + async function refreshLoginToken( + baseUrl: string, + token: LoginToken + ): Promise<LoginResult> { + + if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) { + return { + valid: false, cause: { + type: ErrorType.CLIENT, + status: HttpStatusCode.Unauthorized, + message: "login token expired, login again.", + info: { + hasToken: true, + status: 401, + options: {}, + url: `/private/token`, + payload: {} + }, + payload: {} + }, + } + } + + return requestNewLoginToken(baseUrl, token.token as AccessToken) + } + return { requestNewLoginToken, refreshLoginToken } +} + +/** + * + * @param root the request is intended to the base URL and no the instance URL + * @returns request handler to + */ +export function useBackendBaseRequest(): useBackendBaseRequestType { + const { url: backend, token: loginToken } = useBackendContext(); + const { request: requestHandler } = useApiContext(); + const token = loginToken?.token; + + const request = useCallback( + function requestImpl<T>( + endpoint: string, + options: RequestOptions = {}, + ): Promise<HttpResponseOk<T>> { + return requestHandler<T>(backend, endpoint, { ...options, token }).then(res => { + return res + }).catch(err => { + throw err + }); + }, + [backend, token], + ); + + return { request }; +} + +export function useBackendInstanceRequest(): useBackendInstanceRequestType { + const { url: rootBackendUrl, token: rootToken } = useBackendContext(); + const { token: instanceToken, id, admin } = useInstanceContext(); + const { request: requestHandler } = useApiContext(); + + const { baseUrl, token: loginToken } = !admin + ? { baseUrl: rootBackendUrl, token: rootToken } + : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken }; + + const token = loginToken?.token; + + const request = useCallback( + function requestImpl<T>( + endpoint: string, + options: RequestOptions = {}, + ): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint, { token, ...options }); + }, + [baseUrl, token], + ); + + const multiFetcher = useCallback( + function multiFetcherImpl<T>( + args: [endpoints: string[]], + ): Promise<HttpResponseOk<T>[]> { + const [endpoints] = args + return Promise.all( + endpoints.map((endpoint) => + requestHandler<T>(baseUrl, endpoint, { token }), + ), + ); + }, + [baseUrl, token], + ); + + const fetcher = useCallback( + function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint, { token }); + }, + [baseUrl, token], + ); + + const orderFetcher = useCallback( + function orderFetcherImpl<T>( + args: [endpoint: string, + paid?: YesOrNo, + refunded?: YesOrNo, + wired?: YesOrNo, + searchDate?: Date, + delta?: number,] + ): Promise<HttpResponseOk<T>> { + const [endpoint, paid, refunded, wired, searchDate, delta] = args + const date_s = + delta && delta < 0 && searchDate + ? Math.floor(searchDate.getTime() / 1000) + 1 + : searchDate !== undefined ? Math.floor(searchDate.getTime() / 1000) : undefined; + const params: any = {}; + if (paid !== undefined) params.paid = paid; + if (delta !== undefined) params.delta = delta; + if (refunded !== undefined) params.refunded = refunded; + if (wired !== undefined) params.wired = wired; + if (date_s !== undefined) params.date_s = date_s; + if (delta === 0) { + //in this case we can already assume the response + //and avoid network + return Promise.resolve({ + ok: true, + data: { orders: [] } as T, + }) + } + return requestHandler<T>(baseUrl, endpoint, { params, token }); + }, + [baseUrl, token], + ); + + const reserveDetailFetcher = useCallback( + function reserveDetailFetcherImpl<T>( + endpoint: string, + ): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint, { + params: { + rewards: "yes", + }, + token, + }); + }, + [baseUrl, token], + ); + + const rewardsDetailFetcher = useCallback( + function rewardsDetailFetcherImpl<T>( + endpoint: string, + ): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint, { + params: { + pickups: "yes", + }, + token, + }); + }, + [baseUrl, token], + ); + + const transferFetcher = useCallback( + function transferFetcherImpl<T>( + args: [endpoint: string, + payto_uri?: string, + verified?: string, + position?: string, + delta?: number,] + ): Promise<HttpResponseOk<T>> { + const [endpoint, payto_uri, verified, position, delta] = args + const params: any = {}; + if (payto_uri !== undefined) params.payto_uri = payto_uri; + if (verified !== undefined) params.verified = verified; + if (delta === 0) { + //in this case we can already assume the response + //and avoid network + return Promise.resolve({ + ok: true, + data: { transfers: [] } as T, + }) + } + if (delta !== undefined) { + params.limit = delta; + } + if (position !== undefined) params.offset = position; + + return requestHandler<T>(baseUrl, endpoint, { params, token }); + }, + [baseUrl, token], + ); + + const templateFetcher = useCallback( + function templateFetcherImpl<T>( + args: [endpoint: string, + position?: string, + delta?: number,] + ): Promise<HttpResponseOk<T>> { + const [endpoint, position, delta] = args + const params: any = {}; + if (delta === 0) { + //in this case we can already assume the response + //and avoid network + return Promise.resolve({ + ok: true, + data: { templates: [] } as T, + }) + } + if (delta !== undefined) { + params.limit = delta; + } + if (position !== undefined) params.offset = position; + + return requestHandler<T>(baseUrl, endpoint, { params, token }); + }, + [baseUrl, token], + ); + + const webhookFetcher = useCallback( + function webhookFetcherImpl<T>( + args: [endpoint: string, + position?: string, + delta?: number,] + ): Promise<HttpResponseOk<T>> { + const [endpoint, position, delta] = args + const params: any = {}; + if (delta === 0) { + //in this case we can already assume the response + //and avoid network + return Promise.resolve({ + ok: true, + data: { webhooks: [] } as T, + }) + } + if (delta !== undefined) { + params.limit = delta; + } + if (position !== undefined) params.offset = position; + + return requestHandler<T>(baseUrl, endpoint, { params, token }); + }, + [baseUrl, token], + ); + + return { + request, + fetcher, + multiFetcher, + orderFetcher, + reserveDetailFetcher, + rewardsDetailFetcher, + transferFetcher, + templateFetcher, + webhookFetcher, + }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/bank.ts b/packages/auditor-backoffice-ui/src/hooks/bank.ts new file mode 100644 index 000000000..03b064646 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/bank.ts @@ -0,0 +1,217 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +// const MOCKED_ACCOUNTS: Record<string, MerchantBackend.BankAccounts.AccountAddDetails> = { +// "hwire1": { +// h_wire: "hwire1", +// payto_uri: "payto://fake/iban/123", +// salt: "qwe", +// }, +// "hwire2": { +// h_wire: "hwire2", +// payto_uri: "payto://fake/iban/123", +// salt: "qwe2", +// }, +// } + +export function useBankAccountAPI(): BankAccountAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendInstanceRequest(); + + const createBankAccount = async ( + data: MerchantBackend.BankAccounts.AccountAddDetails, + ): Promise<HttpResponseOk<void>> => { + // MOCKED_ACCOUNTS[data.h_wire] = data + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/accounts`, { + method: "POST", + data, + }); + await mutateAll(/.*private\/accounts.*/); + return res; + }; + + const updateBankAccount = async ( + h_wire: string, + data: MerchantBackend.BankAccounts.AccountPatchDetails, + ): Promise<HttpResponseOk<void>> => { + // MOCKED_ACCOUNTS[h_wire].credit_facade_credentials = data.credit_facade_credentials + // MOCKED_ACCOUNTS[h_wire].credit_facade_url = data.credit_facade_url + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/accounts/${h_wire}`, { + method: "PATCH", + data, + }); + await mutateAll(/.*private\/accounts.*/); + return res; + }; + + const deleteBankAccount = async ( + h_wire: string, + ): Promise<HttpResponseOk<void>> => { + // delete MOCKED_ACCOUNTS[h_wire] + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/accounts/${h_wire}`, { + method: "DELETE", + }); + await mutateAll(/.*private\/accounts.*/); + return res; + }; + + return { + createBankAccount, + updateBankAccount, + deleteBankAccount, + }; +} + +export interface BankAccountAPI { + createBankAccount: ( + data: MerchantBackend.BankAccounts.AccountAddDetails, + ) => Promise<HttpResponseOk<void>>; + updateBankAccount: ( + id: string, + data: MerchantBackend.BankAccounts.AccountPatchDetails, + ) => Promise<HttpResponseOk<void>>; + deleteBankAccount: (id: string) => Promise<HttpResponseOk<void>>; +} + +export interface InstanceBankAccountFilter { +} + +export function useInstanceBankAccounts( + args?: InstanceBankAccountFilter, + updatePosition?: (id: string) => void, +): HttpResponsePaginated< + MerchantBackend.BankAccounts.AccountsSummaryResponse, + MerchantBackend.ErrorDetail +> { + // return { + // ok: true, + // loadMore() { }, + // loadMorePrev() { }, + // data: { + // accounts: Object.values(MOCKED_ACCOUNTS).map(e => ({ + // ...e, + // active: true, + // })) + // } + // } + const { fetcher } = useBackendInstanceRequest(); + + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.BankAccounts.AccountsSummaryResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/accounts`], fetcher); + + const [lastAfter, setLastAfter] = useState< + HttpResponse< + MerchantBackend.BankAccounts.AccountsSummaryResponse, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + }, [afterData /*, beforeData*/]); + + if (afterError) return afterError.cause; + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = + afterData && afterData.data.accounts.length < totalAfter; + const isReachingStart = false; + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.accounts.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = `${afterData.data.accounts[afterData.data.accounts.length - 1] + .h_wire + }`; + if (from && updatePosition) updatePosition(from); + } + }, + loadMorePrev: () => { + }, + }; + + const accounts = !afterData ? [] : (afterData || lastAfter).data.accounts; + if (loadingAfter /* || loadingBefore */) + return { loading: true, data: { accounts } }; + if (/*beforeData &&*/ afterData) { + return { ok: true, data: { accounts }, ...pagination }; + } + return { loading: true }; +} + +export function useBankAccountDetails( + h_wire: string, +): HttpResponse< + MerchantBackend.BankAccounts.BankAccountEntry, + MerchantBackend.ErrorDetail +> { + // return { + // ok: true, + // data: { + // ...MOCKED_ACCOUNTS[h_wire], + // active: true, + // } + // } + const { fetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.BankAccounts.BankAccountEntry>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/accounts/${h_wire}`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) { + return data; + } + if (error) return error.cause; + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts b/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts new file mode 100644 index 000000000..e4ec9a2f2 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts @@ -0,0 +1,161 @@ +/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { AuditorBackend, MerchantBackend, WithId } from "../declaration.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface DepositConfirmationAPI {
+ getDepositConfirmation: (
+ id: string,
+ ) => Promise<void>;
+ createDepositConfirmation: (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ) => Promise<void>;
+ updateDepositConfirmation: (
+ id: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ) => Promise<void>;
+ deleteDepositConfirmation: (id: string) => Promise<void>;
+}
+
+export function useDepositConfirmationAPI(): DepositConfirmationAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+
+ const { request } = useBackendInstanceRequest();
+
+ const createDepositConfirmation = async (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ): Promise<void> => {
+ const res = await request(`/private/products`, {
+ method: "POST",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const updateDepositConfirmation = async (
+ productId: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ): Promise<void> => {
+ const r = await request(`/private/products/${productId}`, {
+ method: "PATCH",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const deleteDepositConfirmation = async (productId: string): Promise<void> => {
+ await request(`/private/products/${productId}`, {
+ method: "DELETE",
+ });
+ await mutate([`/private/products`]);
+ };
+
+ const getDepositConfirmation = async (
+ serialId: string,
+ ): Promise<void> => {
+ await request(`/deposit-confirmation/${serialId}`, {
+ method: "GET",
+ });
+
+ return
+ };
+
+ return {createDepositConfirmation, updateDepositConfirmation, deleteDepositConfirmation, getDepositConfirmation};
+}
+
+export function useDepositConfirmation(): HttpResponse<
+ (AuditorBackend.DepositConfirmation.DepositConfirmationDetail & WithId)[],
+ AuditorBackend.ErrorDetail
+> {
+ const { fetcher, multiFetcher } = useBackendInstanceRequest();
+
+ const { data: list, error: listError } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationList>,
+ RequestError<AuditorBackend.ErrorDetail>
+ >([`/deposit-confirmation`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ const paths = (list?.data.depositConfirmations || []).map(
+ (p) => `/deposit-confirmation/${p.serial_id}`,
+ );
+ const { data: depositConfirmations, error: depositConfirmationError } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>[],
+ RequestError<AuditorBackend.ErrorDetail>
+ >([paths], multiFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (listError) return listError.cause;
+ if (depositConfirmationError) return depositConfirmationError.cause;
+
+ if (depositConfirmations) {
+ const dataWithId = depositConfirmations.map((d) => {
+ //take the id from the queried url
+ return {
+ ...d.data,
+ id: d.info?.url.replace(/.*\/deposit-confirmation\//, "") || "",
+ };
+ });
+ return { ok: true, data: dataWithId };
+ }
+ return { loading: true };
+}
+
+export function useDepositConfirmationDetails(
+ serialId: string,
+): HttpResponse<
+ AuditorBackend.DepositConfirmation.DepositConfirmationDetail,
+ AuditorBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>,
+ RequestError<AuditorBackend.ErrorDetail>
+ >([`/deposit-confirmation/${serialId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/index.ts b/packages/auditor-backoffice-ui/src/hooks/index.ts new file mode 100644 index 000000000..61afbc94a --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/index.ts @@ -0,0 +1,151 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { buildCodecForObject, codecForMap, codecForString, codecForTimestamp } from "@gnu-taler/taler-util"; +import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; +import { StateUpdater, useEffect, useState } from "preact/hooks"; +import { LoginToken } from "../declaration.js"; +import { ValueOrFunction } from "../utils/types.js"; +import { useMatchMutate } from "./backend.js"; + +const calculateRootPath = () => { + const rootPath = + typeof window !== undefined + ? window.location.origin + window.location.pathname + : "/"; + + /** + * By default, merchant backend serves the html content + * from the /webui root. This should cover most of the + * cases and the rootPath will be the merchant backend + * URL where the instances are + */ + return rootPath.replace("/webui/", ""); +}; + +const loginTokenCodec = buildCodecForObject<LoginToken>() + .property("token", codecForString()) + .property("expiration", codecForTimestamp) + .build("loginToken") +const TOKENS_KEY = buildStorageKey("auditor-token", codecForMap(loginTokenCodec)); + + +export function useBackendURL( + url?: string, +): [string, StateUpdater<string>] { + const [value, setter] = useSimpleLocalStorage( + "auditor-base-url", + url || calculateRootPath(), + ); + + const checkedSetter = (v: ValueOrFunction<string>) => { + return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, "")); + }; + + return [value!, checkedSetter]; +} + +export function useBackendDefaultToken( +): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { + const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) + + const tokenOfDefaultInstance = tokenMap["default"] + const clearCache = useMatchMutate() + useEffect(() => { + clearCache() + }, [tokenOfDefaultInstance]) + + function updateToken( + value: (LoginToken | undefined) + ): void { + if (value === undefined) { + reset() + } else { + const res = { ...tokenMap, "default": value } + setToken(res) + } + } + return [tokenMap["default"], updateToken]; +} + +export function useBackendInstanceToken( + id: string, +): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { + const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) + const [defaultToken, defaultSetToken] = useBackendDefaultToken(); + + // instance named 'default' use the default token + if (id === "default") { + return [defaultToken, defaultSetToken]; + } + function updateToken( + value: (LoginToken | undefined) + ): void { + if (value === undefined) { + reset() + } else { + const res = { ...tokenMap, [id]: value } + setToken(res) + } + } + + return [tokenMap[id], updateToken]; +} + +export function useLang(initial?: string): [string, StateUpdater<string>] { + const browserLang = + typeof window !== "undefined" + ? navigator.language || (navigator as any).userLanguage + : undefined; + const defaultLang = (browserLang || initial || "en").substring(0, 2); + return useSimpleLocalStorage("lang-preference", defaultLang) as [string, StateUpdater<string>]; +} + +export function useSimpleLocalStorage( + key: string, + initialValue?: string, +): [string | undefined, StateUpdater<string | undefined>] { + const [storedValue, setStoredValue] = useState<string | undefined>( + (): string | undefined => { + return typeof window !== "undefined" + ? window.localStorage.getItem(key) || initialValue + : initialValue; + }, + ); + + const setValue = ( + value?: string | ((val?: string) => string | undefined), + ) => { + setStoredValue((p) => { + const toStore = value instanceof Function ? value(p) : value; + if (typeof window !== "undefined") { + if (!toStore) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, toStore); + } + } + return toStore; + }); + }; + + return [storedValue, setValue]; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/instance.test.ts b/packages/auditor-backoffice-ui/src/hooks/instance.test.ts new file mode 100644 index 000000000..ee1576764 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/instance.test.ts @@ -0,0 +1,741 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { AccessToken, MerchantBackend } from "../declaration.js"; +import { + useAdminAPI, + useBackendInstances, + useInstanceAPI, + useInstanceDetails, + useManagementAPI, +} from "./instance.js"; +import { ApiMockEnvironment } from "./testing.js"; +import { + API_CREATE_INSTANCE, + API_DELETE_INSTANCE, + API_GET_CURRENT_INSTANCE, + API_LIST_INSTANCES, + API_NEW_LOGIN, + API_UPDATE_CURRENT_INSTANCE, + API_UPDATE_CURRENT_INSTANCE_AUTH, + API_UPDATE_INSTANCE_BY_ID, +} from "./urls.js"; + +describe("instance api interaction with details", () => { + it("should evict cache when updating an instance", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { + response: { + name: "instance_name", + } as MerchantBackend.Instances.QueryInstancesResponse, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useInstanceAPI(); + const query = useInstanceDetails(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + name: "instance_name", + }); + env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE, { + request: { + name: "other_name", + } as MerchantBackend.Instances.InstanceReconfigurationMessage, + }); + env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { + response: { + name: "other_name", + } as MerchantBackend.Instances.QueryInstancesResponse, + }); + api.updateInstance({ + name: "other_name", + } as MerchantBackend.Instances.InstanceReconfigurationMessage); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + name: "other_name", + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when setting the instance's token", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { + response: { + name: "instance_name", + auth: { + method: "token", + // token: "not-secret", + }, + } as MerchantBackend.Instances.QueryInstancesResponse, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useInstanceAPI(); + const query = useInstanceDetails(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + name: "instance_name", + auth: { + method: "token", + }, + }); + env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, { + request: { + method: "token", + token: "secret", + } as MerchantBackend.Instances.InstanceAuthConfigurationMessage, + }); + env.addRequestExpectation(API_NEW_LOGIN, { + auth: "secret", + request: { + scope: "write", + duration: { + "d_us": "forever", + }, + refreshable: true, + }, + }); + env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { + response: { + name: "instance_name", + auth: { + method: "token", + // token: "secret", + }, + } as MerchantBackend.Instances.QueryInstancesResponse, + }); + api.setNewAccessToken(undefined, "secret" as AccessToken); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + name: "instance_name", + auth: { + method: "token", + // token: "secret", + }, + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when clearing the instance's token", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { + response: { + name: "instance_name", + auth: { + method: "token", + // token: "not-secret", + }, + } as MerchantBackend.Instances.QueryInstancesResponse, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useInstanceAPI(); + const query = useInstanceDetails(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + name: "instance_name", + auth: { + method: "token", + // token: "not-secret", + }, + }); + env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, { + request: { + method: "external", + } as MerchantBackend.Instances.InstanceAuthConfigurationMessage, + }); + env.addRequestExpectation(API_GET_CURRENT_INSTANCE, { + response: { + name: "instance_name", + auth: { + method: "external", + }, + } as MerchantBackend.Instances.QueryInstancesResponse, + }); + + api.clearAccessToken(undefined); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + name: "instance_name", + auth: { + method: "external", + }, + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + // const { result, waitForNextUpdate } = renderHook( + // () => { + // const api = useInstanceAPI(); + // const query = useInstanceDetails(); + + // return { query, api }; + // }, + // { wrapper: TestingContext } + // ); + + // expect(result.current).not.undefined; + // if (!result.current) { + // return; + // } + // expect(result.current.query.loading).true; + + // await waitForNextUpdate({ timeout: 1 }); + + // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + + // expect(result.current.query.loading).false; + + // expect(result.current?.query.ok).true; + // if (!result.current?.query.ok) return; + + // expect(result.current.query.data).equals({ + // name: 'instance_name', + // auth: { + // method: 'token', + // token: 'not-secret', + // } + // }); + + // act(async () => { + // await result.current?.api.clearToken(); + // }); + + // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + + // expect(result.current.query.loading).false; + + // await waitForNextUpdate({ timeout: 1 }); + + // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + + // expect(result.current.query.loading).false; + // expect(result.current.query.ok).true; + + // expect(result.current.query.data).equals({ + // name: 'instance_name', + // auth: { + // method: 'external', + // } + // }); + }); +}); + +describe("instance admin api interaction with listing", () => { + it("should evict cache when creating a new instance", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_INSTANCES, { + response: { + instances: [ + { + name: "instance_name", + } as MerchantBackend.Instances.Instance, + ], + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useAdminAPI(); + const query = useBackendInstances(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + instances: [ + { + name: "instance_name", + }, + ], + }); + + env.addRequestExpectation(API_CREATE_INSTANCE, { + request: { + name: "other_name", + } as MerchantBackend.Instances.InstanceConfigurationMessage, + }); + env.addRequestExpectation(API_LIST_INSTANCES, { + response: { + instances: [ + { + name: "instance_name", + } as MerchantBackend.Instances.Instance, + { + name: "other_name", + } as MerchantBackend.Instances.Instance, + ], + }, + }); + + api.createInstance({ + name: "other_name", + } as MerchantBackend.Instances.InstanceConfigurationMessage); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + instances: [ + { + name: "instance_name", + }, + { + name: "other_name", + }, + ], + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when deleting an instance", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_INSTANCES, { + response: { + instances: [ + { + id: "default", + name: "instance_name", + } as MerchantBackend.Instances.Instance, + { + id: "the_id", + name: "second_instance", + } as MerchantBackend.Instances.Instance, + ], + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useAdminAPI(); + const query = useBackendInstances(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + instances: [ + { + id: "default", + name: "instance_name", + }, + { + id: "the_id", + name: "second_instance", + }, + ], + }); + + env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), {}); + env.addRequestExpectation(API_LIST_INSTANCES, { + response: { + instances: [ + { + id: "default", + name: "instance_name", + } as MerchantBackend.Instances.Instance, + ], + }, + }); + + api.deleteInstance("the_id"); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + instances: [ + { + id: "default", + name: "instance_name", + }, + ], + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + + // const { result, waitForNextUpdate } = renderHook( + // () => { + // const api = useAdminAPI(); + // const query = useBackendInstances(); + + // return { query, api }; + // }, + // { wrapper: TestingContext } + // ); + + // expect(result.current).not.undefined; + // if (!result.current) { + // return; + // } + // expect(result.current.query.loading).true; + + // await waitForNextUpdate({ timeout: 1 }); + + // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + + // expect(result.current.query.loading).false; + + // expect(result.current?.query.ok).true; + // if (!result.current?.query.ok) return; + + // expect(result.current.query.data).equals({ + // instances: [{ + // id: 'default', + // name: 'instance_name' + // }, { + // id: 'the_id', + // name: 'second_instance' + // }] + // }); + + // env.addRequestExpectation(API_DELETE_INSTANCE('the_id'), {}); + + // act(async () => { + // await result.current?.api.deleteInstance('the_id'); + // }); + + // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + + // env.addRequestExpectation(API_LIST_INSTANCES, { + // response: { + // instances: [{ + // id: 'default', + // name: 'instance_name' + // } as MerchantBackend.Instances.Instance] + // }, + // }); + + // expect(result.current.query.loading).false; + + // await waitForNextUpdate({ timeout: 1 }); + + // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + + // expect(result.current.query.loading).false; + // expect(result.current.query.ok).true; + + // expect(result.current.query.data).equals({ + // instances: [{ + // id: 'default', + // name: 'instance_name' + // }] + // }); + }); + + it("should evict cache when deleting (purge) an instance", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_INSTANCES, { + response: { + instances: [ + { + id: "default", + name: "instance_name", + } as MerchantBackend.Instances.Instance, + { + id: "the_id", + name: "second_instance", + } as MerchantBackend.Instances.Instance, + ], + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useAdminAPI(); + const query = useBackendInstances(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + instances: [ + { + id: "default", + name: "instance_name", + }, + { + id: "the_id", + name: "second_instance", + }, + ], + }); + + env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), { + qparam: { + purge: "YES", + }, + }); + env.addRequestExpectation(API_LIST_INSTANCES, { + response: { + instances: [ + { + id: "default", + name: "instance_name", + } as MerchantBackend.Instances.Instance, + ], + }, + }); + + api.purgeInstance("the_id"); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + instances: [ + { + id: "default", + name: "instance_name", + }, + ], + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); + +describe("instance management api interaction with listing", () => { + it("should evict cache when updating an instance", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_INSTANCES, { + response: { + instances: [ + { + id: "managed", + name: "instance_name", + } as MerchantBackend.Instances.Instance, + ], + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useManagementAPI("managed"); + const query = useBackendInstances(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + instances: [ + { + id: "managed", + name: "instance_name", + }, + ], + }); + + env.addRequestExpectation(API_UPDATE_INSTANCE_BY_ID("managed"), { + request: { + name: "other_name", + } as MerchantBackend.Instances.InstanceReconfigurationMessage, + }); + env.addRequestExpectation(API_LIST_INSTANCES, { + response: { + instances: [ + { + id: "managed", + name: "other_name", + } as MerchantBackend.Instances.Instance, + ], + }, + }); + + api.updateInstance({ + name: "other_name", + } as MerchantBackend.Instances.InstanceConfigurationMessage); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + instances: [ + { + id: "managed", + name: "other_name", + }, + ], + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); diff --git a/packages/auditor-backoffice-ui/src/hooks/instance.ts b/packages/auditor-backoffice-ui/src/hooks/instance.ts new file mode 100644 index 000000000..0677191db --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/instance.ts @@ -0,0 +1,313 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useBackendContext } from "../context/backend.js"; +import { AccessToken, MerchantBackend } from "../declaration.js"; +import { + useBackendBaseRequest, + useBackendInstanceRequest, + useCredentialsChecker, + useMatchMutate, +} from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +interface InstanceAPI { + updateInstance: ( + data: MerchantBackend.Instances.InstanceReconfigurationMessage, + ) => Promise<void>; + deleteInstance: () => Promise<void>; + clearAccessToken: (currentToken: AccessToken | undefined) => Promise<void>; + setNewAccessToken: (currentToken: AccessToken | undefined, token: AccessToken) => Promise<void>; +} + +export function useAdminAPI(): AdminAPI { + const { request } = useBackendBaseRequest(); + const mutateAll = useMatchMutate(); + + const createInstance = async ( + instance: MerchantBackend.Instances.InstanceConfigurationMessage, + ): Promise<void> => { + await request(`/management/instances`, { + method: "POST", + data: instance, + }); + + mutateAll(/\/management\/instances/); + }; + + const deleteInstance = async (id: string): Promise<void> => { + await request(`/management/instances/${id}`, { + method: "DELETE", + }); + + mutateAll(/\/management\/instances/); + }; + + const purgeInstance = async (id: string): Promise<void> => { + await request(`/management/instances/${id}`, { + method: "DELETE", + params: { + purge: "YES", + }, + }); + + mutateAll(/\/management\/instances/); + }; + + return { createInstance, deleteInstance, purgeInstance }; +} + +export interface AdminAPI { + createInstance: ( + data: MerchantBackend.Instances.InstanceConfigurationMessage, + ) => Promise<void>; + deleteInstance: (id: string) => Promise<void>; + purgeInstance: (id: string) => Promise<void>; +} + +export function useManagementAPI(instanceId: string): InstanceAPI { + const mutateAll = useMatchMutate(); + const { url: backendURL } = useBackendContext() + const { updateToken } = useBackendContext(); + const { request } = useBackendBaseRequest(); + const { requestNewLoginToken } = useCredentialsChecker() + + const updateInstance = async ( + instance: MerchantBackend.Instances.InstanceReconfigurationMessage, + ): Promise<void> => { + await request(`/management/instances/${instanceId}`, { + method: "PATCH", + data: instance, + }); + + mutateAll(/\/management\/instances/); + }; + + const deleteInstance = async (): Promise<void> => { + await request(`/management/instances/${instanceId}`, { + method: "DELETE", + }); + + mutateAll(/\/management\/instances/); + }; + + const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => { + await request(`/management/instances/${instanceId}/auth`, { + method: "POST", + token: currentToken, + data: { method: "external" }, + }); + + mutateAll(/\/management\/instances/); + }; + + const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => { + await request(`/management/instances/${instanceId}/auth`, { + method: "POST", + token: currentToken, + data: { method: "token", token: newToken }, + }); + + const resp = await requestNewLoginToken(backendURL, newToken) + if (resp.valid) { + const { token, expiration } = resp + updateToken({ token, expiration }); + } else { + updateToken(undefined) + } + + mutateAll(/\/management\/instances/); + }; + + return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken }; +} + +export function useInstanceAPI(): InstanceAPI { + const { mutate } = useSWRConfig(); + const { url: backendURL, updateToken } = useBackendContext() + + const { + token: adminToken, + } = useBackendContext(); + const { request } = useBackendInstanceRequest(); + const { requestNewLoginToken } = useCredentialsChecker() + + const updateInstance = async ( + instance: MerchantBackend.Instances.InstanceReconfigurationMessage, + ): Promise<void> => { + await request(`/private/`, { + method: "PATCH", + data: instance, + }); + + if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); + mutate([`/private/`], null); + }; + + const deleteInstance = async (): Promise<void> => { + await request(`/private/`, { + method: "DELETE", + // token: adminToken, + }); + + if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); + mutate([`/private/`], null); + }; + + const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => { + await request(`/private/auth`, { + method: "POST", + token: currentToken, + data: { method: "external" }, + }); + + mutate([`/private/`], null); + }; + + const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => { + await request(`/private/auth`, { + method: "POST", + token: currentToken, + data: { method: "token", token: newToken }, + }); + + const resp = await requestNewLoginToken(backendURL, newToken) + if (resp.valid) { + const { token, expiration } = resp + updateToken({ token, expiration }); + } else { + updateToken(undefined) + } + + mutate([`/private/`], null); + }; + + return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken }; +} + +export function useInstanceDetails(): HttpResponse< + MerchantBackend.Instances.QueryInstancesResponse, + MerchantBackend.ErrorDetail +> { + const { fetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + revalidateIfStale: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} + +type KYCStatus = + | { type: "ok" } + | { type: "redirect"; status: MerchantBackend.KYC.AccountKycRedirects }; + +export function useInstanceKYCDetails(): HttpResponse< + KYCStatus, + MerchantBackend.ErrorDetail +> { + const { fetcher } = useBackendInstanceRequest(); + + const { data, error } = useSWR< + HttpResponseOk<MerchantBackend.KYC.AccountKycRedirects>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/kyc`], fetcher, { + refreshInterval: 60 * 1000, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + }); + + if (data) { + if (data.info?.status === 202) + return { ok: true, data: { type: "redirect", status: data.data } }; + return { ok: true, data: { type: "ok" } }; + } + if (error) return error.cause; + return { loading: true }; +} + +export function useManagedInstanceDetails( + instanceId: string, +): HttpResponse< + MerchantBackend.Instances.QueryInstancesResponse, + MerchantBackend.ErrorDetail +> { + const { request } = useBackendBaseRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/management/instances/${instanceId}`], request, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} + +export function useBackendInstances(): HttpResponse< + MerchantBackend.Instances.InstancesResponse, + MerchantBackend.ErrorDetail +> { + const { request } = useBackendBaseRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Instances.InstancesResponse>, + RequestError<MerchantBackend.ErrorDetail> + >(["/management/instances"], request); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/listener.ts b/packages/auditor-backoffice-ui/src/hooks/listener.ts new file mode 100644 index 000000000..d101f7bb8 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/listener.ts @@ -0,0 +1,85 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useState } from "preact/hooks"; + +/** + * This component is used when a component wants one child to have a trigger for + * an action (a button) and other child have the action implemented (like + * gathering information with a form). The difference with other approaches is + * that in this case the parent component is not holding the state. + * + * It will return a subscriber and activator. + * + * The activator may be undefined, if it is undefined it is indicating that the + * subscriber is not ready to be called. + * + * The subscriber will receive a function (the listener) that will be call when the + * activator runs. The listener must return the collected information. + * + * As a result, when the activator is triggered by a child component, the + * @action function is called receives the information from the listener defined by other + * child component + * + * @param action from <T> to <R> + * @returns activator and subscriber, undefined activator means that there is not subscriber + */ + +export function useListener<T, R = any>( + action: (r: T) => Promise<R>, +): [undefined | (() => Promise<R>), (listener?: () => T) => void] { + type RunnerHandler = { toBeRan?: () => Promise<R> }; + const [state, setState] = useState<RunnerHandler>({}); + + /** + * subscriber will receive a method that will be call when the activator runs + * + * @param listener function to be run when the activator runs + */ + const subscriber = (listener?: () => T) => { + if (listener) { + setState({ + toBeRan: () => { + const whatWeGetFromTheListener = listener(); + return action(whatWeGetFromTheListener); + }, + }); + } else { + setState({ + toBeRan: undefined, + }); + } + }; + + /** + * activator will call runner if there is someone subscribed + */ + const activator = state.toBeRan + ? async () => { + if (state.toBeRan) { + return state.toBeRan(); + } + return Promise.reject(); + } + : undefined; + + return [activator, subscriber]; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/notifications.ts b/packages/auditor-backoffice-ui/src/hooks/notifications.ts new file mode 100644 index 000000000..133ddd80b --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/notifications.ts @@ -0,0 +1,56 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useState } from "preact/hooks"; +import { Notification } from "../utils/types.js"; + +interface Result { + notifications: Notification[]; + pushNotification: (n: Notification) => void; + removeNotification: (n: Notification) => void; +} + +type NotificationWithDate = Notification & { since: Date }; + +export function useNotifications( + initial: Notification[] = [], + timeout = 3000, +): Result { + const [notifications, setNotifications] = useState<NotificationWithDate[]>( + initial.map((i) => ({ ...i, since: new Date() })), + ); + + const pushNotification = (n: Notification): void => { + const entry = { ...n, since: new Date() }; + setNotifications((ns) => [...ns, entry]); + if (n.type !== "ERROR") + setTimeout(() => { + setNotifications((ns) => ns.filter((x) => x.since !== entry.since)); + }, timeout); + }; + + const removeNotification = (notif: Notification) => { + setNotifications((ns: NotificationWithDate[]) => + ns.filter((n) => n !== notif), + ); + }; + return { notifications, pushNotification, removeNotification }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/order.test.ts b/packages/auditor-backoffice-ui/src/hooks/order.test.ts new file mode 100644 index 000000000..c243309a8 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/order.test.ts @@ -0,0 +1,587 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { MerchantBackend } from "../declaration.js"; +import { useInstanceOrders, useOrderAPI, useOrderDetails } from "./order.js"; +import { ApiMockEnvironment } from "./testing.js"; +import { + API_CREATE_ORDER, + API_DELETE_ORDER, + API_FORGET_ORDER_BY_ID, + API_GET_ORDER_BY_ID, + API_LIST_ORDERS, + API_REFUND_ORDER_BY_ID, +} from "./urls.js"; + +describe("order api interaction with listing", () => { + it("should evict cache when creating an order", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -20, paid: "yes" }, + response: { + orders: [{ order_id: "1" }, { order_id: "2" } as MerchantBackend.Orders.OrderHistoryEntry], + }, + }); + + const newDate = (d: Date) => { + //console.log("new date", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useInstanceOrders({ paid: "yes" }, newDate); + const api = useOrderAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [{ order_id: "1" }, { order_id: "2" }], + }); + + env.addRequestExpectation(API_CREATE_ORDER, { + request: { + order: { amount: "ARS:12", summary: "pay me" }, + }, + response: { order_id: "3" }, + }); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -20, paid: "yes" }, + response: { + orders: [{ order_id: "1" }, { order_id: "2" } as any, { order_id: "3" } as any], + }, + }); + + api.createOrder({ + order: { amount: "ARS:12", summary: "pay me" }, + } as any); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [{ order_id: "1" }, { order_id: "2" }, { order_id: "3" }], + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when doing a refund", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -20, paid: "yes" }, + response: { orders: [{ + order_id: "1", + amount: "EUR:12", + refundable: true, + } as MerchantBackend.Orders.OrderHistoryEntry] }, + }); + + const newDate = (d: Date) => { + //console.log("new date", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useInstanceOrders({ paid: "yes" }, newDate); + const api = useOrderAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [ + { + order_id: "1", + amount: "EUR:12", + refundable: true, + }, + ], + }); + env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), { + request: { + reason: "double pay", + refund: "EUR:1", + }, + }); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -20, paid: "yes" }, + response: { orders: [ + { order_id: "1", amount: "EUR:12", refundable: false } as any, + ] }, + }); + + api.refundOrder("1", { + reason: "double pay", + refund: "EUR:1", + }); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [ + { + order_id: "1", + amount: "EUR:12", + refundable: false, + }, + ], + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when deleting an order", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -20, paid: "yes" }, + response: { + orders: [{ order_id: "1" }, { order_id: "2" } as MerchantBackend.Orders.OrderHistoryEntry], + }, + }); + + const newDate = (d: Date) => { + //console.log("new date", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useInstanceOrders({ paid: "yes" }, newDate); + const api = useOrderAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [{ order_id: "1" }, { order_id: "2" }], + }); + + env.addRequestExpectation(API_DELETE_ORDER("1"), {}); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -20, paid: "yes" }, + response: { + orders: [{ order_id: "2" } as any], + }, + }); + + api.deleteOrder("1"); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [{ order_id: "2" }], + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); + +describe("order api interaction with details", () => { + it("should evict cache when doing a refund", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), { + // qparam: { delta: 0, paid: "yes" }, + response: { + summary: "description", + refund_amount: "EUR:0", + } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse, + }); + + const newDate = (d: Date) => { + //console.log("new date", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useOrderDetails("1"); + const api = useOrderAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + summary: "description", + refund_amount: "EUR:0", + }); + env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), { + request: { + reason: "double pay", + refund: "EUR:1", + }, + }); + + env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), { + response: { + summary: "description", + refund_amount: "EUR:1", + } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse, + }); + + api.refundOrder("1", { + reason: "double pay", + refund: "EUR:1", + }); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + summary: "description", + refund_amount: "EUR:1", + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when doing a forget", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), { + // qparam: { delta: 0, paid: "yes" }, + response: { + summary: "description", + refund_amount: "EUR:0", + } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse, + }); + + const newDate = (d: Date) => { + //console.log("new date", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useOrderDetails("1"); + const api = useOrderAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + summary: "description", + refund_amount: "EUR:0", + }); + env.addRequestExpectation(API_FORGET_ORDER_BY_ID("1"), { + request: { + fields: ["$.summary"], + }, + }); + + env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), { + response: { + summary: undefined, + } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse, + }); + + api.forgetOrder("1", { + fields: ["$.summary"], + }); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + summary: undefined, + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); + +describe("order listing pagination", () => { + it("should not load more if has reach the end", async () => { + const env = new ApiMockEnvironment(); + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: 20, wired: "yes", date_s: 12 }, + response: { + orders: [{ order_id: "1" } as any], + }, + }); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -20, wired: "yes", date_s: 13 }, + response: { + orders: [{ order_id: "2" } as any], + }, + }); + + const newDate = (d: Date) => { + //console.log("new date", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const date = new Date(12000); + const query = useInstanceOrders({ wired: "yes", date }, newDate); + const api = useOrderAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [{ order_id: "1" }, { order_id: "2" }], + }); + expect(query.isReachingEnd).true; + expect(query.isReachingStart).true; + + // should not trigger new state update or query + query.loadMore(); + query.loadMorePrev(); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should load more if result brings more that PAGE_SIZE", async () => { + const env = new ApiMockEnvironment(); + + const ordersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({ + order_id: String(i), + })); + const ordersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({ + order_id: String(i + 20), + })); + const ordersFrom20to0 = [...ordersFrom0to20].reverse(); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: 20, wired: "yes", date_s: 12 }, + response: { + orders: ordersFrom0to20, + }, + }); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -20, wired: "yes", date_s: 13 }, + response: { + orders: ordersFrom20to40, + }, + }); + + const newDate = (d: Date) => { + //console.log("new date", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const date = new Date(12000); + const query = useInstanceOrders({ wired: "yes", date }, newDate); + const api = useOrderAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [...ordersFrom20to0, ...ordersFrom20to40], + }); + expect(query.isReachingEnd).false; + expect(query.isReachingStart).false; + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: -40, wired: "yes", date_s: 13 }, + response: { + orders: [...ordersFrom20to40, { order_id: "41" }], + }, + }); + + query.loadMore(); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).true; + }, + ({ query, api }) => { + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [ + ...ordersFrom20to0, + ...ordersFrom20to40, + { order_id: "41" }, + ], + }); + + env.addRequestExpectation(API_LIST_ORDERS, { + qparam: { delta: 40, wired: "yes", date_s: 12 }, + response: { + orders: [...ordersFrom0to20, { order_id: "-1" }], + }, + }); + + query.loadMorePrev(); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).true; + }, + ({ query, api }) => { + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + orders: [ + { order_id: "-1" }, + ...ordersFrom20to0, + ...ordersFrom20to40, + { order_id: "41" }, + ], + }); + }, + ], + env.buildTestingContext(), + ); + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); diff --git a/packages/auditor-backoffice-ui/src/hooks/order.ts b/packages/auditor-backoffice-ui/src/hooks/order.ts new file mode 100644 index 000000000..e7a893f2c --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/order.ts @@ -0,0 +1,289 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +export interface OrderAPI { + //FIXME: add OutOfStockResponse on 410 + createOrder: ( + data: MerchantBackend.Orders.PostOrderRequest, + ) => Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>>; + forgetOrder: ( + id: string, + data: MerchantBackend.Orders.ForgetRequest, + ) => Promise<HttpResponseOk<void>>; + refundOrder: ( + id: string, + data: MerchantBackend.Orders.RefundRequest, + ) => Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>>; + deleteOrder: (id: string) => Promise<HttpResponseOk<void>>; + getPaymentURL: (id: string) => Promise<HttpResponseOk<string>>; +} + +type YesOrNo = "yes" | "no"; + +export function useOrderAPI(): OrderAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendInstanceRequest(); + + const createOrder = async ( + data: MerchantBackend.Orders.PostOrderRequest, + ): Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>> => { + const res = await request<MerchantBackend.Orders.PostOrderResponse>( + `/private/orders`, + { + method: "POST", + data, + }, + ); + await mutateAll(/.*private\/orders.*/); + // mutate('') + return res; + }; + const refundOrder = async ( + orderId: string, + data: MerchantBackend.Orders.RefundRequest, + ): Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>> => { + mutateAll(/@"\/private\/orders"@/); + const res = request<MerchantBackend.Orders.MerchantRefundResponse>( + `/private/orders/${orderId}/refund`, + { + method: "POST", + data, + }, + ); + + // order list returns refundable information, so we must evict everything + await mutateAll(/.*private\/orders.*/); + return res; + }; + + const forgetOrder = async ( + orderId: string, + data: MerchantBackend.Orders.ForgetRequest, + ): Promise<HttpResponseOk<void>> => { + mutateAll(/@"\/private\/orders"@/); + const res = request<void>(`/private/orders/${orderId}/forget`, { + method: "PATCH", + data, + }); + // we may be forgetting some fields that are pare of the listing, so we must evict everything + await mutateAll(/.*private\/orders.*/); + return res; + }; + const deleteOrder = async ( + orderId: string, + ): Promise<HttpResponseOk<void>> => { + mutateAll(/@"\/private\/orders"@/); + const res = request<void>(`/private/orders/${orderId}`, { + method: "DELETE", + }); + await mutateAll(/.*private\/orders.*/); + return res; + }; + + const getPaymentURL = async ( + orderId: string, + ): Promise<HttpResponseOk<string>> => { + return request<MerchantBackend.Orders.MerchantOrderStatusResponse>( + `/private/orders/${orderId}`, + { + method: "GET", + }, + ).then((res) => { + const url = + res.data.order_status === "unpaid" + ? res.data.taler_pay_uri + : res.data.contract_terms.fulfillment_url; + const response: HttpResponseOk<string> = res as any; + response.data = url || ""; + return response; + }); + }; + + return { createOrder, forgetOrder, deleteOrder, refundOrder, getPaymentURL }; +} + +export function useOrderDetails( + oderId: string, +): HttpResponse< + MerchantBackend.Orders.MerchantOrderStatusResponse, + MerchantBackend.ErrorDetail +> { + const { fetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Orders.MerchantOrderStatusResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/orders/${oderId}`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} + +export interface InstanceOrderFilter { + paid?: YesOrNo; + refunded?: YesOrNo; + wired?: YesOrNo; + date?: Date; +} + +export function useInstanceOrders( + args?: InstanceOrderFilter, + updateFilter?: (d: Date) => void, +): HttpResponsePaginated< + MerchantBackend.Orders.OrderHistory, + MerchantBackend.ErrorDetail +> { + const { orderFetcher } = useBackendInstanceRequest(); + + const [pageBefore, setPageBefore] = useState(1); + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + const totalBefore = args?.date ? pageBefore * PAGE_SIZE : 0; + + /** + * FIXME: this can be cleaned up a little + * + * the logic of double query should be inside the orderFetch so from the hook perspective and cache + * is just one query and one error status + */ + const { + data: beforeData, + error: beforeError, + isValidating: loadingBefore, + } = useSWR< + HttpResponseOk<MerchantBackend.Orders.OrderHistory>, + RequestError<MerchantBackend.ErrorDetail> + >( + [ + `/private/orders`, + args?.paid, + args?.refunded, + args?.wired, + args?.date, + totalBefore, + ], + orderFetcher, + ); + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.Orders.OrderHistory>, + RequestError<MerchantBackend.ErrorDetail> + >( + [ + `/private/orders`, + args?.paid, + args?.refunded, + args?.wired, + args?.date, + -totalAfter, + ], + orderFetcher, + ); + + //this will save last result + const [lastBefore, setLastBefore] = useState< + HttpResponse< + MerchantBackend.Orders.OrderHistory, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + const [lastAfter, setLastAfter] = useState< + HttpResponse< + MerchantBackend.Orders.OrderHistory, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + if (beforeData) setLastBefore(beforeData); + }, [afterData, beforeData]); + + if (beforeError) return beforeError.cause; + if (afterError) return afterError.cause; + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = afterData && afterData.data.orders.length < totalAfter; + const isReachingStart = + args?.date === undefined || + (beforeData && beforeData.data.orders.length < totalBefore); + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.orders.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = + afterData.data.orders[afterData.data.orders.length - 1].timestamp.t_s; + if (from && from !== "never" && updateFilter) + updateFilter(new Date(from * 1000)); + } + }, + loadMorePrev: () => { + if (!beforeData || isReachingStart) return; + if (beforeData.data.orders.length < MAX_RESULT_SIZE) { + setPageBefore(pageBefore + 1); + } else if (beforeData) { + const from = + beforeData.data.orders[beforeData.data.orders.length - 1].timestamp + .t_s; + if (from && from !== "never" && updateFilter) + updateFilter(new Date(from * 1000)); + } + }, + }; + + const orders = + !beforeData || !afterData + ? [] + : (beforeData || lastBefore).data.orders + .slice() + .reverse() + .concat((afterData || lastAfter).data.orders); + if (loadingAfter || loadingBefore) return { loading: true, data: { orders } }; + if (beforeData && afterData) { + return { ok: true, data: { orders }, ...pagination }; + } + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/otp.ts b/packages/auditor-backoffice-ui/src/hooks/otp.ts new file mode 100644 index 000000000..b045e365a --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/otp.ts @@ -0,0 +1,223 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +const MOCKED_DEVICES: Record<string, MerchantBackend.OTP.OtpDeviceAddDetails> = { + "1": { + otp_device_description: "first device", + otp_algorithm: 1, + otp_device_id: "1", + otp_key: "123", + }, + "2": { + otp_device_description: "second device", + otp_algorithm: 0, + otp_device_id: "2", + otp_key: "456", + } +} + +export function useOtpDeviceAPI(): OtpDeviceAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendInstanceRequest(); + + const createOtpDevice = async ( + data: MerchantBackend.OTP.OtpDeviceAddDetails, + ): Promise<HttpResponseOk<void>> => { + // MOCKED_DEVICES[data.otp_device_id] = data + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/otp-devices`, { + method: "POST", + data, + }); + await mutateAll(/.*private\/otp-devices.*/); + return res; + }; + + const updateOtpDevice = async ( + deviceId: string, + data: MerchantBackend.OTP.OtpDevicePatchDetails, + ): Promise<HttpResponseOk<void>> => { + // MOCKED_DEVICES[deviceId].otp_algorithm = data.otp_algorithm + // MOCKED_DEVICES[deviceId].otp_ctr = data.otp_ctr + // MOCKED_DEVICES[deviceId].otp_device_description = data.otp_device_description + // MOCKED_DEVICES[deviceId].otp_key = data.otp_key + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/otp-devices/${deviceId}`, { + method: "PATCH", + data, + }); + await mutateAll(/.*private\/otp-devices.*/); + return res; + }; + + const deleteOtpDevice = async ( + deviceId: string, + ): Promise<HttpResponseOk<void>> => { + // delete MOCKED_DEVICES[deviceId] + // return Promise.resolve({ ok: true, data: undefined }); + const res = await request<void>(`/private/otp-devices/${deviceId}`, { + method: "DELETE", + }); + await mutateAll(/.*private\/otp-devices.*/); + return res; + }; + + return { + createOtpDevice, + updateOtpDevice, + deleteOtpDevice, + }; +} + +export interface OtpDeviceAPI { + createOtpDevice: ( + data: MerchantBackend.OTP.OtpDeviceAddDetails, + ) => Promise<HttpResponseOk<void>>; + updateOtpDevice: ( + id: string, + data: MerchantBackend.OTP.OtpDevicePatchDetails, + ) => Promise<HttpResponseOk<void>>; + deleteOtpDevice: (id: string) => Promise<HttpResponseOk<void>>; +} + +export interface InstanceOtpDeviceFilter { +} + +export function useInstanceOtpDevices( + args?: InstanceOtpDeviceFilter, + updatePosition?: (id: string) => void, +): HttpResponsePaginated< + MerchantBackend.OTP.OtpDeviceSummaryResponse, + MerchantBackend.ErrorDetail +> { + // return { + // ok: true, + // loadMore: () => { }, + // loadMorePrev: () => { }, + // data: { + // otp_devices: Object.values(MOCKED_DEVICES).map(d => ({ + // device_description: d.otp_device_description, + // otp_device_id: d.otp_device_id + // })) + // } + // } + + const { fetcher } = useBackendInstanceRequest(); + + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.OTP.OtpDeviceSummaryResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/otp-devices`], fetcher); + + const [lastAfter, setLastAfter] = useState< + HttpResponse< + MerchantBackend.OTP.OtpDeviceSummaryResponse, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + }, [afterData /*, beforeData*/]); + + if (afterError) return afterError.cause; + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = + afterData && afterData.data.otp_devices.length < totalAfter; + const isReachingStart = true; + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.otp_devices.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = `${afterData.data.otp_devices[afterData.data.otp_devices.length - 1] + .otp_device_id + }`; + if (from && updatePosition) updatePosition(from); + } + }, + loadMorePrev: () => { + }, + }; + + const otp_devices = !afterData ? [] : (afterData || lastAfter).data.otp_devices; + if (loadingAfter /* || loadingBefore */) + return { loading: true, data: { otp_devices } }; + if (/*beforeData &&*/ afterData) { + return { ok: true, data: { otp_devices }, ...pagination }; + } + return { loading: true }; +} + +export function useOtpDeviceDetails( + deviceId: string, +): HttpResponse< + MerchantBackend.OTP.OtpDeviceDetails, + MerchantBackend.ErrorDetail +> { + // return { + // ok: true, + // data: { + // device_description: MOCKED_DEVICES[deviceId].otp_device_description, + // otp_algorithm: MOCKED_DEVICES[deviceId].otp_algorithm, + // otp_ctr: MOCKED_DEVICES[deviceId].otp_ctr + // } + // } + const { fetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.OTP.OtpDeviceDetails>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/otp-devices/${deviceId}`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) { + return data; + } + if (error) return error.cause; + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/product.test.ts b/packages/auditor-backoffice-ui/src/hooks/product.test.ts new file mode 100644 index 000000000..7cac10e25 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/product.test.ts @@ -0,0 +1,362 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { MerchantBackend } from "../declaration.js"; +import { + useInstanceProducts, + useProductAPI, + useProductDetails, +} from "./product.js"; +import { ApiMockEnvironment } from "./testing.js"; +import { + API_CREATE_PRODUCT, + API_DELETE_PRODUCT, + API_GET_PRODUCT_BY_ID, + API_LIST_PRODUCTS, + API_UPDATE_PRODUCT_BY_ID, +} from "./urls.js"; + +describe("product api interaction with listing", () => { + it("should evict cache when creating a product", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_PRODUCTS, { + response: { + products: [{ product_id: "1234" }], + }, + }); + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { + response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useInstanceProducts(); + const api = useProductAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).true; + }, + ({ query, api }) => { + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]); + + env.addRequestExpectation(API_CREATE_PRODUCT, { + request: { + price: "ARS:23", + } as MerchantBackend.Products.ProductAddDetail, + }); + + env.addRequestExpectation(API_LIST_PRODUCTS, { + response: { + products: [{ product_id: "1234" }, { product_id: "2345" }], + }, + }); + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { + response: { + price: "ARS:12", + } as MerchantBackend.Products.ProductDetail, + }); + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { + response: { + price: "ARS:12", + } as MerchantBackend.Products.ProductDetail, + }); + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), { + response: { + price: "ARS:23", + } as MerchantBackend.Products.ProductDetail, + }); + + api.createProduct({ + price: "ARS:23", + } as any); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals([ + { + id: "1234", + price: "ARS:12", + }, + { + id: "2345", + price: "ARS:23", + }, + ]); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when updating a product", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_PRODUCTS, { + response: { + products: [{ product_id: "1234" }], + }, + }); + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { + response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useInstanceProducts(); + const api = useProductAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]); + + env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("1234"), { + request: { + price: "ARS:13", + } as MerchantBackend.Products.ProductPatchDetail, + }); + + env.addRequestExpectation(API_LIST_PRODUCTS, { + response: { + products: [{ product_id: "1234" }], + }, + }); + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { + response: { + price: "ARS:13", + } as MerchantBackend.Products.ProductDetail, + }); + + api.updateProduct("1234", { + price: "ARS:13", + } as any); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals([ + { + id: "1234", + price: "ARS:13", + }, + ]); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when deleting a product", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_PRODUCTS, { + response: { + products: [{ product_id: "1234" }, { product_id: "2345" }], + }, + }); + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { + response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail, + }); + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), { + response: { price: "ARS:23" } as MerchantBackend.Products.ProductDetail, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useInstanceProducts(); + const api = useProductAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).true; + }, + ({ query, api }) => { + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals([ + { id: "1234", price: "ARS:12" }, + { id: "2345", price: "ARS:23" }, + ]); + + env.addRequestExpectation(API_DELETE_PRODUCT("2345"), {}); + + env.addRequestExpectation(API_LIST_PRODUCTS, { + response: { + products: [{ product_id: "1234" }], + }, + }); + + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), { + response: { + price: "ARS:12", + } as MerchantBackend.Products.ProductDetail, + }); + api.deleteProduct("2345"); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); + +describe("product api interaction with details", () => { + it("should evict cache when updating a product", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), { + response: { + description: "this is a description", + } as MerchantBackend.Products.ProductDetail, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useProductDetails("12"); + const api = useProductAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + description: "this is a description", + }); + + env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("12"), { + request: { + description: "other description", + } as MerchantBackend.Products.ProductPatchDetail, + }); + + env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), { + response: { + description: "other description", + } as MerchantBackend.Products.ProductDetail, + }); + + api.updateProduct("12", { + description: "other description", + } as any); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + description: "other description", + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); diff --git a/packages/auditor-backoffice-ui/src/hooks/product.ts b/packages/auditor-backoffice-ui/src/hooks/product.ts new file mode 100644 index 000000000..b8f55cb77 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/product.ts @@ -0,0 +1,177 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { AuditorBackend, MerchantBackend, WithId } from "../declaration.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +export interface ProductAPI { + getProduct: ( + id: string, + ) => Promise<void>; + createProduct: ( + data: MerchantBackend.Products.ProductAddDetail, + ) => Promise<void>; + updateProduct: ( + id: string, + data: MerchantBackend.Products.ProductPatchDetail, + ) => Promise<void>; + deleteProduct: (id: string) => Promise<void>; + lockProduct: ( + id: string, + data: MerchantBackend.Products.LockRequest, + ) => Promise<void>; +} + +export function useProductAPI(): ProductAPI { + const mutateAll = useMatchMutate(); + const { mutate } = useSWRConfig(); + + const { request } = useBackendInstanceRequest(); + + const createProduct = async ( + data: MerchantBackend.Products.ProductAddDetail, + ): Promise<void> => { + const res = await request(`/private/products`, { + method: "POST", + data, + }); + + return await mutateAll(/.*\/private\/products.*/); + }; + + const updateProduct = async ( + productId: string, + data: MerchantBackend.Products.ProductPatchDetail, + ): Promise<void> => { + const r = await request(`/private/products/${productId}`, { + method: "PATCH", + data, + }); + + return await mutateAll(/.*\/private\/products.*/); + }; + + const deleteProduct = async (productId: string): Promise<void> => { + await request(`/private/products/${productId}`, { + method: "DELETE", + }); + await mutate([`/private/products`]); + }; + + const lockProduct = async ( + productId: string, + data: MerchantBackend.Products.LockRequest, + ): Promise<void> => { + await request(`/private/products/${productId}/lock`, { + method: "POST", + data, + }); + + return await mutateAll(/.*"\/private\/products.*/); + }; + + const getProduct = async ( + productId: string, + ): Promise<void> => { + await request(`/private/products/${productId}`, { + method: "GET", + }); + + return + }; + + return { createProduct, updateProduct, deleteProduct, lockProduct, getProduct }; +} + +export function useInstanceProducts(): HttpResponse< + (MerchantBackend.Products.ProductDetail & WithId)[], + MerchantBackend.ErrorDetail +> { + const { fetcher, multiFetcher } = useBackendInstanceRequest(); + + const { data: list, error: listError } = useSWR< + HttpResponseOk<MerchantBackend.Products.InventorySummaryResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/products`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + const paths = (list?.data.products || []).map( + (p) => `/deposit-confirmation/${p.serial_id}`, + ); + const { data: products, error: productError } = useSWR< + HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>[], + RequestError<AuditorBackend.ErrorDetail> + >([paths], multiFetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (listError) return listError.cause; + if (productError) return productError.cause; + + if (products) { + const dataWithId = products.map((d) => { + //take the id from the queried url + return { + ...d.data, + id: d.info?.url.replace(/.*\/private\/products\//, "") || "", + }; + }); + return { ok: true, data: dataWithId }; + } + return { loading: true }; +} + +export function useProductDetails( + productId: string, +): HttpResponse< + MerchantBackend.Products.ProductDetail, + MerchantBackend.ErrorDetail +> { + const { fetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Products.ProductDetail>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/products/${productId}`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts b/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts new file mode 100644 index 000000000..b3eecd754 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts @@ -0,0 +1,448 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { expect } from "chai"; +import { MerchantBackend } from "../declaration.js"; +import { + useInstanceReserves, + useReserveDetails, + useReservesAPI, + useRewardDetails, +} from "./reserves.js"; +import { ApiMockEnvironment } from "./testing.js"; +import { + API_AUTHORIZE_REWARD, + API_AUTHORIZE_REWARD_FOR_RESERVE, + API_CREATE_RESERVE, + API_DELETE_RESERVE, + API_GET_RESERVE_BY_ID, + API_GET_REWARD_BY_ID, + API_LIST_RESERVES, +} from "./urls.js"; +import * as tests from "@gnu-taler/web-util/testing"; + +describe("reserve api interaction with listing", () => { + it("should evict cache when creating a reserve", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_RESERVES, { + response: { + reserves: [ + { + reserve_pub: "11", + } as MerchantBackend.Rewards.ReserveStatusEntry, + ], + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useReservesAPI(); + const query = useInstanceReserves(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + reserves: [{ reserve_pub: "11" }], + }); + + env.addRequestExpectation(API_CREATE_RESERVE, { + request: { + initial_balance: "ARS:3333", + exchange_url: "http://url", + wire_method: "iban", + }, + response: { + reserve_pub: "22", + accounts: [], + }, + }); + + env.addRequestExpectation(API_LIST_RESERVES, { + response: { + reserves: [ + { + reserve_pub: "11", + } as MerchantBackend.Rewards.ReserveStatusEntry, + { + reserve_pub: "22", + } as MerchantBackend.Rewards.ReserveStatusEntry, + ], + }, + }); + + api.createReserve({ + initial_balance: "ARS:3333", + exchange_url: "http://url", + wire_method: "iban", + }); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + + expect(query.data).deep.equals({ + reserves: [ + { + reserve_pub: "11", + } as MerchantBackend.Rewards.ReserveStatusEntry, + { + reserve_pub: "22", + } as MerchantBackend.Rewards.ReserveStatusEntry, + ], + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when deleting a reserve", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_RESERVES, { + response: { + reserves: [ + { + reserve_pub: "11", + } as MerchantBackend.Rewards.ReserveStatusEntry, + { + reserve_pub: "22", + } as MerchantBackend.Rewards.ReserveStatusEntry, + { + reserve_pub: "33", + } as MerchantBackend.Rewards.ReserveStatusEntry, + ], + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useReservesAPI(); + const query = useInstanceReserves(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + reserves: [ + { reserve_pub: "11" }, + { reserve_pub: "22" }, + { reserve_pub: "33" }, + ], + }); + + env.addRequestExpectation(API_DELETE_RESERVE("11"), {}); + env.addRequestExpectation(API_LIST_RESERVES, { + response: { + reserves: [ + { + reserve_pub: "22", + } as MerchantBackend.Rewards.ReserveStatusEntry, + { + reserve_pub: "33", + } as MerchantBackend.Rewards.ReserveStatusEntry, + ], + }, + }); + + api.deleteReserve("11"); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + reserves: [{ reserve_pub: "22" }, { reserve_pub: "33" }], + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); + +describe("reserve api interaction with details", () => { + it("should evict cache when adding a reward for a specific reserve", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { + response: { + accounts: [{ payto_uri: "payto://here" }], + rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], + } as MerchantBackend.Rewards.ReserveDetail, + qparam: { + rewards: "yes", + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useReservesAPI(); + const query = useReserveDetails("11"); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + accounts: [{ payto_uri: "payto://here" }], + rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], + }); + + env.addRequestExpectation(API_AUTHORIZE_REWARD_FOR_RESERVE("11"), { + request: { + amount: "USD:12", + justification: "not", + next_url: "http://taler.net", + }, + response: { + reward_id: "id2", + taler_reward_uri: "uri", + reward_expiration: { t_s: 1 }, + reward_status_url: "url", + }, + }); + + env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { + response: { + accounts: [{ payto_uri: "payto://here" }], + rewards: [ + { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, + { reason: "not", reward_id: "id2", total_amount: "USD:12" }, + ], + } as MerchantBackend.Rewards.ReserveDetail, + qparam: { + rewards: "yes", + }, + }); + + api.authorizeRewardReserve("11", { + amount: "USD:12", + justification: "not", + next_url: "http://taler.net", + }); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + + expect(query.data).deep.equals({ + accounts: [{ payto_uri: "payto://here" }], + rewards: [ + { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, + { reason: "not", reward_id: "id2", total_amount: "USD:12" }, + ], + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + it("should evict cache when adding a reward for a random reserve", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { + response: { + accounts: [{ payto_uri: "payto://here" }], + rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], + } as MerchantBackend.Rewards.ReserveDetail, + qparam: { + rewards: "yes", + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const api = useReservesAPI(); + const query = useReserveDetails("11"); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + accounts: [{ payto_uri: "payto://here" }], + rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }], + }); + + env.addRequestExpectation(API_AUTHORIZE_REWARD, { + request: { + amount: "USD:12", + justification: "not", + next_url: "http://taler.net", + }, + response: { + reward_id: "id2", + taler_reward_uri: "uri", + reward_expiration: { t_s: 1 }, + reward_status_url: "url", + }, + }); + + env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), { + response: { + accounts: [{ payto_uri: "payto://here" }], + rewards: [ + { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, + { reason: "not", reward_id: "id2", total_amount: "USD:12" }, + ], + } as MerchantBackend.Rewards.ReserveDetail, + qparam: { + rewards: "yes", + }, + }); + + api.authorizeReward({ + amount: "USD:12", + justification: "not", + next_url: "http://taler.net", + }); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + + expect(query.data).deep.equals({ + accounts: [{ payto_uri: "payto://here" }], + rewards: [ + { reason: "why?", reward_id: "id1", total_amount: "USD:10" }, + { reason: "not", reward_id: "id2", total_amount: "USD:12" }, + ], + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); + +describe("reserve api interaction with reward details", () => { + it("should list rewards", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_GET_REWARD_BY_ID("11"), { + response: { + total_picked_up: "USD:12", + reason: "not", + } as MerchantBackend.Rewards.RewardDetails, + qparam: { + pickups: "yes", + }, + }); + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useRewardDetails("11"); + return { query }; + }, + {}, + [ + ({ query }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).true; + }, + ({ query }) => { + expect(query.loading).false; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + total_picked_up: "USD:12", + reason: "not", + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); diff --git a/packages/auditor-backoffice-ui/src/hooks/reserves.ts b/packages/auditor-backoffice-ui/src/hooks/reserves.ts new file mode 100644 index 000000000..b719bfbe6 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/reserves.ts @@ -0,0 +1,181 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { MerchantBackend } from "../declaration.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +export function useReservesAPI(): ReserveMutateAPI { + const mutateAll = useMatchMutate(); + const { mutate } = useSWRConfig(); + const { request } = useBackendInstanceRequest(); + + const createReserve = async ( + data: MerchantBackend.Rewards.ReserveCreateRequest, + ): Promise< + HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation> + > => { + const res = await request<MerchantBackend.Rewards.ReserveCreateConfirmation>( + `/private/reserves`, + { + method: "POST", + data, + }, + ); + + //evict reserve list query + await mutateAll(/.*private\/reserves.*/); + + return res; + }; + + const authorizeRewardReserve = async ( + pub: string, + data: MerchantBackend.Rewards.RewardCreateRequest, + ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => { + const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>( + `/private/reserves/${pub}/authorize-reward`, + { + method: "POST", + data, + }, + ); + + //evict reserve details query + await mutate([`/private/reserves/${pub}`]); + + return res; + }; + + const authorizeReward = async ( + data: MerchantBackend.Rewards.RewardCreateRequest, + ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => { + const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>( + `/private/rewards`, + { + method: "POST", + data, + }, + ); + + //evict all details query + await mutateAll(/.*private\/reserves\/.*/); + + return res; + }; + + const deleteReserve = async ( + pub: string, + ): Promise<HttpResponse<void, MerchantBackend.ErrorDetail>> => { + const res = await request<void>(`/private/reserves/${pub}`, { + method: "DELETE", + }); + + //evict reserve list query + await mutateAll(/.*private\/reserves.*/); + + return res; + }; + + return { createReserve, authorizeReward, authorizeRewardReserve, deleteReserve }; +} + +export interface ReserveMutateAPI { + createReserve: ( + data: MerchantBackend.Rewards.ReserveCreateRequest, + ) => Promise<HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>>; + authorizeRewardReserve: ( + id: string, + data: MerchantBackend.Rewards.RewardCreateRequest, + ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>; + authorizeReward: ( + data: MerchantBackend.Rewards.RewardCreateRequest, + ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>; + deleteReserve: ( + id: string, + ) => Promise<HttpResponse<void, MerchantBackend.ErrorDetail>>; +} + +export function useInstanceReserves(): HttpResponse< + MerchantBackend.Rewards.RewardReserveStatus, + MerchantBackend.ErrorDetail +> { + const { fetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Rewards.RewardReserveStatus>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/reserves`], fetcher); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} + +export function useReserveDetails( + reserveId: string, +): HttpResponse< + MerchantBackend.Rewards.ReserveDetail, + MerchantBackend.ErrorDetail +> { + const { reserveDetailFetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Rewards.ReserveDetail>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/reserves/${reserveId}`], reserveDetailFetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} + +export function useRewardDetails( + rewardId: string, +): HttpResponse<MerchantBackend.Rewards.RewardDetails, MerchantBackend.ErrorDetail> { + const { rewardsDetailFetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Rewards.RewardDetails>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/rewards/${rewardId}`], rewardsDetailFetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/templates.ts b/packages/auditor-backoffice-ui/src/hooks/templates.ts new file mode 100644 index 000000000..ee8728cc8 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/templates.ts @@ -0,0 +1,266 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +export function useTemplateAPI(): TemplateAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendInstanceRequest(); + + const createTemplate = async ( + data: MerchantBackend.Template.TemplateAddDetails, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`/private/templates`, { + method: "POST", + data, + }); + await mutateAll(/.*private\/templates.*/); + return res; + }; + + const updateTemplate = async ( + templateId: string, + data: MerchantBackend.Template.TemplatePatchDetails, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`/private/templates/${templateId}`, { + method: "PATCH", + data, + }); + await mutateAll(/.*private\/templates.*/); + return res; + }; + + const deleteTemplate = async ( + templateId: string, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`/private/templates/${templateId}`, { + method: "DELETE", + }); + await mutateAll(/.*private\/templates.*/); + return res; + }; + + const createOrderFromTemplate = async ( + templateId: string, + data: MerchantBackend.Template.UsingTemplateDetails, + ): Promise< + HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse> + > => { + const res = await request<MerchantBackend.Template.UsingTemplateResponse>( + `/templates/${templateId}`, + { + method: "POST", + data, + }, + ); + await mutateAll(/.*private\/templates.*/); + return res; + }; + + const testTemplateExist = async ( + templateId: string, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`/private/templates/${templateId}`, { method: "GET", }); + return res; + }; + + + return { + createTemplate, + updateTemplate, + deleteTemplate, + testTemplateExist, + createOrderFromTemplate, + }; +} + +export interface TemplateAPI { + createTemplate: ( + data: MerchantBackend.Template.TemplateAddDetails, + ) => Promise<HttpResponseOk<void>>; + updateTemplate: ( + id: string, + data: MerchantBackend.Template.TemplatePatchDetails, + ) => Promise<HttpResponseOk<void>>; + testTemplateExist: ( + id: string + ) => Promise<HttpResponseOk<void>>; + deleteTemplate: (id: string) => Promise<HttpResponseOk<void>>; + createOrderFromTemplate: ( + id: string, + data: MerchantBackend.Template.UsingTemplateDetails, + ) => Promise<HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>>; +} + +export interface InstanceTemplateFilter { + //FIXME: add filter to the template list + position?: string; +} + +export function useInstanceTemplates( + args?: InstanceTemplateFilter, + updatePosition?: (id: string) => void, +): HttpResponsePaginated< + MerchantBackend.Template.TemplateSummaryResponse, + MerchantBackend.ErrorDetail +> { + const { templateFetcher } = useBackendInstanceRequest(); + + const [pageBefore, setPageBefore] = useState(1); + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + const totalBefore = args?.position ? pageBefore * PAGE_SIZE : 0; + + /** + * FIXME: this can be cleaned up a little + * + * the logic of double query should be inside the orderFetch so from the hook perspective and cache + * is just one query and one error status + */ + const { + data: beforeData, + error: beforeError, + isValidating: loadingBefore, + } = useSWR< + HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>, + RequestError<MerchantBackend.ErrorDetail>>( + [ + `/private/templates`, + args?.position, + totalBefore, + ], + templateFetcher, + ); + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/templates`, args?.position, -totalAfter], templateFetcher); + + //this will save last result + const [lastBefore, setLastBefore] = useState< + HttpResponse< + MerchantBackend.Template.TemplateSummaryResponse, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + + const [lastAfter, setLastAfter] = useState< + HttpResponse< + MerchantBackend.Template.TemplateSummaryResponse, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + if (beforeData) setLastBefore(beforeData); + }, [afterData, beforeData]); + + if (beforeError) return beforeError.cause; + if (afterError) return afterError.cause; + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = + afterData && afterData.data.templates.length < totalAfter; + const isReachingStart = args?.position === undefined + || + (beforeData && beforeData.data.templates.length < totalBefore); + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.templates.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = `${afterData.data.templates[afterData.data.templates.length - 1] + .template_id + }`; + if (from && updatePosition) updatePosition(from); + } + }, + loadMorePrev: () => { + if (!beforeData || isReachingStart) return; + if (beforeData.data.templates.length < MAX_RESULT_SIZE) { + setPageBefore(pageBefore + 1); + } else if (beforeData) { + const from = `${beforeData.data.templates[beforeData.data.templates.length - 1] + .template_id + }`; + if (from && updatePosition) updatePosition(from); + } + }, + }; + + // const templates = !afterData ? [] : (afterData || lastAfter).data.templates; + const templates = + !beforeData || !afterData + ? [] + : (beforeData || lastBefore).data.templates + .slice() + .reverse() + .concat((afterData || lastAfter).data.templates); + if (loadingAfter || loadingBefore) + return { loading: true, data: { templates } }; + if (beforeData && afterData) { + return { ok: true, data: { templates }, ...pagination }; + } + return { loading: true }; +} + +export function useTemplateDetails( + templateId: string, +): HttpResponse< + MerchantBackend.Template.TemplateDetails, + MerchantBackend.ErrorDetail +> { + const { templateFetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Template.TemplateDetails>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/templates/${templateId}`], templateFetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) { + return data; + } + if (error) return error.cause; + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/testing.tsx b/packages/auditor-backoffice-ui/src/hooks/testing.tsx new file mode 100644 index 000000000..3ea22475b --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/testing.tsx @@ -0,0 +1,180 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { MockEnvironment } from "@gnu-taler/web-util/testing"; +import { ComponentChildren, FunctionalComponent, h, VNode } from "preact"; +import { HttpRequestLibrary, HttpRequestOptions, HttpResponse } from "@gnu-taler/taler-util/http"; +import { SWRConfig } from "swr"; +import { ApiContextProvider } from "@gnu-taler/web-util/browser"; +import { BackendContextProvider } from "../context/backend.js"; +import { InstanceContextProvider } from "../context/instance.js"; +import { HttpResponseOk, RequestOptions } from "@gnu-taler/web-util/browser"; +import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient } from "@gnu-taler/taler-util"; + +export class ApiMockEnvironment extends MockEnvironment { + constructor(debug = false) { + super(debug); + } + + mockApiIfNeeded(): void { + null; // do nothing + } + + public buildTestingContext(): FunctionalComponent<{ + children: ComponentChildren; + }> { + const __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE = + this.saveRequestAndGetMockedResponse.bind(this); + + return function TestingContext({ + children, + }: { + children: ComponentChildren; + }): VNode { + + async function request<T>( + base: string, + path: string, + options: RequestOptions = {}, + ): Promise<HttpResponseOk<T>> { + const _url = new URL(`${base}${path}`); + // Object.entries(options.params ?? {}).forEach(([key, value]) => { + // _url.searchParams.set(key, String(value)); + // }); + + const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE( + { + method: options.method ?? "GET", + url: _url.href, + }, + { + qparam: options.params, + auth: options.token, + request: options.data, + }, + ); + const status = mocked.expectedQuery?.query.code ?? 200; + const requestPayload = mocked.expectedQuery?.params?.request; + const responsePayload = mocked.expectedQuery?.params?.response; + + return { + ok: true, + data: responsePayload as T, + loading: false, + clientError: false, + serverError: false, + info: { + hasToken: !!options.token, + status, + url: _url.href, + payload: options.data, + options: {}, + }, + }; + } + const SC: any = SWRConfig; + + const mockHttpClient = new class implements HttpRequestLibrary { + async fetch(url: string, options?: HttpRequestOptions | undefined): Promise<HttpResponse> { + const _url = new URL(url); + const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE( + { + method: options?.method ?? "GET", + url: _url.href, + }, + { + qparam: _url.searchParams, + auth: options as any, + request: options?.body as any, + }, + ); + const status = mocked.expectedQuery?.query.code ?? 200; + const requestPayload = mocked.expectedQuery?.params?.request; + const responsePayload = mocked.expectedQuery?.params?.response; + + // FIXME: complete this implementation to mock any query + const resp: HttpResponse = { + requestUrl: _url.href, + status: status, + headers: {} as any, + requestMethod: options?.method ?? "GET", + json: async () => responsePayload, + text: async () => responsePayload as any as string, + bytes: async () => responsePayload as ArrayBuffer, + }; + return resp + } + get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { + return this.fetch(url, { + method: "GET", + ...opt, + }); + } + + postJson( + url: string, + body: any, + opt?: HttpRequestOptions, + ): Promise<HttpResponse> { + return this.fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + ...opt, + }); + } + + } + const bankCore = new TalerCoreBankHttpClient("http://localhost", mockHttpClient) + const bankIntegration = bankCore.getIntegrationAPI() + const bankRevenue = bankCore.getRevenueAPI("a") + const bankWire = bankCore.getWireGatewayAPI("b") + + return ( + <BackendContextProvider defaultUrl="http://backend"> + <InstanceContextProvider + value={{ + token: undefined, + id: "default", + admin: true, + changeToken: () => null, + }} + > + <ApiContextProvider value={{ request, bankCore, bankIntegration, bankRevenue, bankWire }}> + <SC + value={{ + loadingTimeout: 0, + dedupingInterval: 0, + shouldRetryOnError: false, + errorRetryInterval: 0, + errorRetryCount: 0, + provider: () => new Map(), + }} + > + {children} + </SC> + </ApiContextProvider> + </InstanceContextProvider> + </BackendContextProvider> + ); + }; + } +} diff --git a/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts b/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts new file mode 100644 index 000000000..a7187af27 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts @@ -0,0 +1,254 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { MerchantBackend } from "../declaration.js"; +import { API_INFORM_TRANSFERS, API_LIST_TRANSFERS } from "./urls.js"; +import { ApiMockEnvironment } from "./testing.js"; +import { useInstanceTransfers, useTransferAPI } from "./transfer.js"; + +describe("transfer api interaction with listing", () => { + it("should evict cache when informing a transfer", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_TRANSFERS, { + qparam: { limit: -20 }, + response: { + transfers: [{ wtid: "2" } as MerchantBackend.Transfers.TransferDetails], + }, + }); + + const moveCursor = (d: string) => { + console.log("new position", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + const query = useInstanceTransfers({}, moveCursor); + const api = useTransferAPI(); + return { query, api }; + }, + {}, + [ + ({ query, api }) => { + expect(query.loading).true; + }, + + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + transfers: [{ wtid: "2" }], + }); + + env.addRequestExpectation(API_INFORM_TRANSFERS, { + request: { + wtid: "3", + credit_amount: "EUR:1", + exchange_url: "exchange.url", + payto_uri: "payto://", + }, + response: { total: "" } as any, + }); + + env.addRequestExpectation(API_LIST_TRANSFERS, { + qparam: { limit: -20 }, + response: { + transfers: [{ wtid: "3" } as any, { wtid: "2" } as any], + }, + }); + + api.informTransfer({ + wtid: "3", + credit_amount: "EUR:1", + exchange_url: "exchange.url", + payto_uri: "payto://", + }); + }, + ({ query, api }) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + + expect(query.data).deep.equals({ + transfers: [{ wtid: "3" }, { wtid: "2" }], + }); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); + +describe("transfer listing pagination", () => { + it("should not load more if has reach the end", async () => { + const env = new ApiMockEnvironment(); + + env.addRequestExpectation(API_LIST_TRANSFERS, { + qparam: { limit: -20, payto_uri: "payto://" }, + response: { + transfers: [{ wtid: "2" }, { wtid: "1" } as any], + }, + }); + + const moveCursor = (d: string) => { + console.log("new position", d); + }; + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + return useInstanceTransfers({ payto_uri: "payto://" }, moveCursor); + }, + {}, + [ + (query) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(query.loading).true; + }, + (query) => { + expect(query.loading).undefined; + expect(query.ok).true; + if (!query.ok) return; + expect(query.data).deep.equals({ + transfers: [{ wtid: "2" }, { wtid: "1" }], + }); + expect(query.isReachingEnd).true; + expect(query.isReachingStart).true; + + //check that this button won't trigger more updates since + //has reach end and start + query.loadMore(); + query.loadMorePrev(); + }, + ], + env.buildTestingContext(), + ); + + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + expect(hookBehavior).deep.eq({ result: "ok" }); + }); + + it("should load more if result brings more that PAGE_SIZE", async () => { + const env = new ApiMockEnvironment(); + + const transfersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({ + wtid: String(i), + })); + const transfersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({ + wtid: String(i + 20), + })); + const transfersFrom20to0 = [...transfersFrom0to20].reverse(); + + env.addRequestExpectation(API_LIST_TRANSFERS, { + qparam: { limit: 20, payto_uri: "payto://", offset: "1" }, + response: { + transfers: transfersFrom0to20, + }, + }); + + env.addRequestExpectation(API_LIST_TRANSFERS, { + qparam: { limit: -20, payto_uri: "payto://", offset: "1" }, + response: { + transfers: transfersFrom20to40, + }, + }); + + const moveCursor = (d: string) => { + console.log("new position", d); + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + () => { + return useInstanceTransfers( + { payto_uri: "payto://", position: "1" }, + moveCursor, + ); + }, + {}, + [ + (result) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(result.loading).true; + }, + (result) => { + expect(result.loading).undefined; + expect(result.ok).true; + if (!result.ok) return; + expect(result.data).deep.equals({ + transfers: [...transfersFrom20to0, ...transfersFrom20to40], + }); + expect(result.isReachingEnd).false; + expect(result.isReachingStart).false; + + //query more + env.addRequestExpectation(API_LIST_TRANSFERS, { + qparam: { limit: -40, payto_uri: "payto://", offset: "1" }, + response: { + transfers: [...transfersFrom20to40, { wtid: "41" }], + }, + }); + result.loadMore(); + }, + (result) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(result.loading).true; + }, + (result) => { + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ + result: "ok", + }); + expect(result.loading).undefined; + expect(result.ok).true; + if (!result.ok) return; + expect(result.data).deep.equals({ + transfers: [ + ...transfersFrom20to0, + ...transfersFrom20to40, + { wtid: "41" }, + ], + }); + expect(result.isReachingEnd).true; + expect(result.isReachingStart).false; + }, + ], + env.buildTestingContext(), + ); + + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + expect(hookBehavior).deep.eq({ result: "ok" }); + }); +}); diff --git a/packages/auditor-backoffice-ui/src/hooks/transfer.ts b/packages/auditor-backoffice-ui/src/hooks/transfer.ts new file mode 100644 index 000000000..27c3bdc75 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/transfer.ts @@ -0,0 +1,188 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +export function useTransferAPI(): TransferAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendInstanceRequest(); + + const informTransfer = async ( + data: MerchantBackend.Transfers.TransferInformation, + ): Promise<HttpResponseOk<{}>> => { + const res = await request<{}>(`/private/transfers`, { + method: "POST", + data, + }); + + await mutateAll(/.*private\/transfers.*/); + return res; + }; + + return { informTransfer }; +} + +export interface TransferAPI { + informTransfer: ( + data: MerchantBackend.Transfers.TransferInformation, + ) => Promise<HttpResponseOk<{}>>; +} + +export interface InstanceTransferFilter { + payto_uri?: string; + verified?: "yes" | "no"; + position?: string; +} + +export function useInstanceTransfers( + args?: InstanceTransferFilter, + updatePosition?: (id: string) => void, +): HttpResponsePaginated< + MerchantBackend.Transfers.TransferList, + MerchantBackend.ErrorDetail +> { + const { transferFetcher } = useBackendInstanceRequest(); + + const [pageBefore, setPageBefore] = useState(1); + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + const totalBefore = args?.position !== undefined ? pageBefore * PAGE_SIZE : 0; + + /** + * FIXME: this can be cleaned up a little + * + * the logic of double query should be inside the orderFetch so from the hook perspective and cache + * is just one query and one error status + */ + const { + data: beforeData, + error: beforeError, + isValidating: loadingBefore, + } = useSWR< + HttpResponseOk<MerchantBackend.Transfers.TransferList>, + RequestError<MerchantBackend.ErrorDetail> + >( + [ + `/private/transfers`, + args?.payto_uri, + args?.verified, + args?.position, + totalBefore, + ], + transferFetcher, + ); + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.Transfers.TransferList>, + RequestError<MerchantBackend.ErrorDetail> + >( + [ + `/private/transfers`, + args?.payto_uri, + args?.verified, + args?.position, + -totalAfter, + ], + transferFetcher, + ); + + //this will save last result + const [lastBefore, setLastBefore] = useState< + HttpResponse< + MerchantBackend.Transfers.TransferList, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + const [lastAfter, setLastAfter] = useState< + HttpResponse< + MerchantBackend.Transfers.TransferList, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + if (beforeData) setLastBefore(beforeData); + }, [afterData, beforeData]); + + if (beforeError) return beforeError.cause; + if (afterError) return afterError.cause; + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = + afterData && afterData.data.transfers.length < totalAfter; + const isReachingStart = + args?.position === undefined || + (beforeData && beforeData.data.transfers.length < totalBefore); + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.transfers.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = `${ + afterData.data.transfers[afterData.data.transfers.length - 1] + .transfer_serial_id + }`; + if (from && updatePosition) updatePosition(from); + } + }, + loadMorePrev: () => { + if (!beforeData || isReachingStart) return; + if (beforeData.data.transfers.length < MAX_RESULT_SIZE) { + setPageBefore(pageBefore + 1); + } else if (beforeData) { + const from = `${ + beforeData.data.transfers[beforeData.data.transfers.length - 1] + .transfer_serial_id + }`; + if (from && updatePosition) updatePosition(from); + } + }, + }; + + const transfers = + !beforeData || !afterData + ? [] + : (beforeData || lastBefore).data.transfers + .slice() + .reverse() + .concat((afterData || lastAfter).data.transfers); + if (loadingAfter || loadingBefore) + return { loading: true, data: { transfers } }; + if (beforeData && afterData) { + return { ok: true, data: { transfers }, ...pagination }; + } + return { loading: true }; +} diff --git a/packages/auditor-backoffice-ui/src/hooks/urls.ts b/packages/auditor-backoffice-ui/src/hooks/urls.ts new file mode 100644 index 000000000..b6485259f --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/urls.ts @@ -0,0 +1,303 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { Query } from "@gnu-taler/web-util/testing"; +import { MerchantBackend } from "../declaration.js"; + +//////////////////// +// ORDER +//////////////////// + +export const API_CREATE_ORDER: Query< + MerchantBackend.Orders.PostOrderRequest, + MerchantBackend.Orders.PostOrderResponse +> = { + method: "POST", + url: "http://backend/instances/default/private/orders", +}; + +export const API_GET_ORDER_BY_ID = ( + id: string, +): Query<unknown, MerchantBackend.Orders.MerchantOrderStatusResponse> => ({ + method: "GET", + url: `http://backend/instances/default/private/orders/${id}`, +}); + +export const API_LIST_ORDERS: Query< + unknown, + MerchantBackend.Orders.OrderHistory +> = { + method: "GET", + url: "http://backend/instances/default/private/orders", +}; + +export const API_REFUND_ORDER_BY_ID = ( + id: string, +): Query< + MerchantBackend.Orders.RefundRequest, + MerchantBackend.Orders.MerchantRefundResponse +> => ({ + method: "POST", + url: `http://backend/instances/default/private/orders/${id}/refund`, +}); + +export const API_FORGET_ORDER_BY_ID = ( + id: string, +): Query<MerchantBackend.Orders.ForgetRequest, unknown> => ({ + method: "PATCH", + url: `http://backend/instances/default/private/orders/${id}/forget`, +}); + +export const API_DELETE_ORDER = ( + id: string, +): Query<MerchantBackend.Orders.ForgetRequest, unknown> => ({ + method: "DELETE", + url: `http://backend/instances/default/private/orders/${id}`, +}); + +//////////////////// +// TRANSFER +//////////////////// + +export const API_LIST_TRANSFERS: Query< + unknown, + MerchantBackend.Transfers.TransferList +> = { + method: "GET", + url: "http://backend/instances/default/private/transfers", +}; + +export const API_INFORM_TRANSFERS: Query< + MerchantBackend.Transfers.TransferInformation, + {} +> = { + method: "POST", + url: "http://backend/instances/default/private/transfers", +}; + +//////////////////// +// PRODUCT +//////////////////// + +export const API_CREATE_PRODUCT: Query< + MerchantBackend.Products.ProductAddDetail, + unknown +> = { + method: "POST", + url: "http://backend/instances/default/private/products", +}; + +export const API_LIST_PRODUCTS: Query< + unknown, + MerchantBackend.Products.InventorySummaryResponse +> = { + method: "GET", + url: "http://backend/instances/default/private/products", +}; + +export const API_GET_PRODUCT_BY_ID = ( + id: string, +): Query<unknown, MerchantBackend.Products.ProductDetail> => ({ + method: "GET", + url: `http://backend/instances/default/private/products/${id}`, +}); + +export const API_UPDATE_PRODUCT_BY_ID = ( + id: string, +): Query< + MerchantBackend.Products.ProductPatchDetail, + MerchantBackend.Products.InventorySummaryResponse +> => ({ + method: "PATCH", + url: `http://backend/instances/default/private/products/${id}`, +}); + +export const API_DELETE_PRODUCT = (id: string): Query<unknown, unknown> => ({ + method: "DELETE", + url: `http://backend/instances/default/private/products/${id}`, +}); + +//////////////////// +// RESERVES +//////////////////// + +export const API_CREATE_RESERVE: Query< + MerchantBackend.Rewards.ReserveCreateRequest, + MerchantBackend.Rewards.ReserveCreateConfirmation +> = { + method: "POST", + url: "http://backend/instances/default/private/reserves", +}; +export const API_LIST_RESERVES: Query< + unknown, + MerchantBackend.Rewards.RewardReserveStatus +> = { + method: "GET", + url: "http://backend/instances/default/private/reserves", +}; + +export const API_GET_RESERVE_BY_ID = ( + pub: string, +): Query<unknown, MerchantBackend.Rewards.ReserveDetail> => ({ + method: "GET", + url: `http://backend/instances/default/private/reserves/${pub}`, +}); + +export const API_GET_REWARD_BY_ID = ( + pub: string, +): Query<unknown, MerchantBackend.Rewards.RewardDetails> => ({ + method: "GET", + url: `http://backend/instances/default/private/rewards/${pub}`, +}); + +export const API_AUTHORIZE_REWARD_FOR_RESERVE = ( + pub: string, +): Query< + MerchantBackend.Rewards.RewardCreateRequest, + MerchantBackend.Rewards.RewardCreateConfirmation +> => ({ + method: "POST", + url: `http://backend/instances/default/private/reserves/${pub}/authorize-reward`, +}); + +export const API_AUTHORIZE_REWARD: Query< + MerchantBackend.Rewards.RewardCreateRequest, + MerchantBackend.Rewards.RewardCreateConfirmation +> = { + method: "POST", + url: `http://backend/instances/default/private/rewards`, +}; + +export const API_DELETE_RESERVE = (id: string): Query<unknown, unknown> => ({ + method: "DELETE", + url: `http://backend/instances/default/private/reserves/${id}`, +}); + +//////////////////// +// INSTANCE ADMIN +//////////////////// + +export const API_CREATE_INSTANCE: Query< + MerchantBackend.Instances.InstanceConfigurationMessage, + unknown +> = { + method: "POST", + url: "http://backend/management/instances", +}; + +export const API_GET_INSTANCE_BY_ID = ( + id: string, +): Query<unknown, MerchantBackend.Instances.QueryInstancesResponse> => ({ + method: "GET", + url: `http://backend/management/instances/${id}`, +}); + +export const API_GET_INSTANCE_KYC_BY_ID = ( + id: string, +): Query<unknown, MerchantBackend.KYC.AccountKycRedirects> => ({ + method: "GET", + url: `http://backend/management/instances/${id}/kyc`, +}); + +export const API_LIST_INSTANCES: Query< + unknown, + MerchantBackend.Instances.InstancesResponse +> = { + method: "GET", + url: "http://backend/management/instances", +}; + +export const API_UPDATE_INSTANCE_BY_ID = ( + id: string, +): Query< + MerchantBackend.Instances.InstanceReconfigurationMessage, + unknown +> => ({ + method: "PATCH", + url: `http://backend/management/instances/${id}`, +}); + +export const API_UPDATE_INSTANCE_AUTH_BY_ID = ( + id: string, +): Query< + MerchantBackend.Instances.InstanceAuthConfigurationMessage, + unknown +> => ({ + method: "POST", + url: `http://backend/management/instances/${id}/auth`, +}); + +export const API_DELETE_INSTANCE = (id: string): Query<unknown, unknown> => ({ + method: "DELETE", + url: `http://backend/management/instances/${id}`, +}); + +//////////////////// +// AUTH +//////////////////// + +export const API_NEW_LOGIN: Query< + MerchantBackend.Instances.LoginTokenRequest, + unknown +> = ({ + method: "POST", + url: `http://backend/private/token`, +}); + +//////////////////// +// INSTANCE +//////////////////// + +export const API_GET_CURRENT_INSTANCE: Query< + unknown, + MerchantBackend.Instances.QueryInstancesResponse +> = { + method: "GET", + url: `http://backend/instances/default/private/`, +}; + +export const API_GET_CURRENT_INSTANCE_KYC: Query< + unknown, + MerchantBackend.KYC.AccountKycRedirects +> = { + method: "GET", + url: `http://backend/instances/default/private/kyc`, +}; + +export const API_UPDATE_CURRENT_INSTANCE: Query< + MerchantBackend.Instances.InstanceReconfigurationMessage, + unknown +> = { + method: "PATCH", + url: `http://backend/instances/default/private/`, +}; + +export const API_UPDATE_CURRENT_INSTANCE_AUTH: Query< + MerchantBackend.Instances.InstanceAuthConfigurationMessage, + unknown +> = { + method: "POST", + url: `http://backend/instances/default/private/auth`, +}; + +export const API_DELETE_CURRENT_INSTANCE: Query<unknown, unknown> = { + method: "DELETE", + url: `http://backend/instances/default/private`, +}; diff --git a/packages/auditor-backoffice-ui/src/hooks/useSettings.ts b/packages/auditor-backoffice-ui/src/hooks/useSettings.ts new file mode 100644 index 000000000..8c1ebd9f6 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/useSettings.ts @@ -0,0 +1,73 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; +import { + Codec, + buildCodecForObject, + codecForBoolean, + codecForConstString, + codecForEither, + codecForString, +} from "@gnu-taler/taler-util"; + +export interface Settings { + advanceOrderMode: boolean; + dateFormat: "ymd" | "dmy" | "mdy"; +} + +const defaultSettings: Settings = { + advanceOrderMode: false, + dateFormat: "ymd", +} + +export const codecForSettings = (): Codec<Settings> => + buildCodecForObject<Settings>() + .property("advanceOrderMode", codecForBoolean()) + .property("dateFormat", codecForEither( + codecForConstString("ymd"), + codecForConstString("dmy"), + codecForConstString("mdy"), + )) + .build("Settings"); + +const SETTINGS_KEY = buildStorageKey("merchant-settings", codecForSettings()); + +export function useSettings(): [ + Readonly<Settings>, + (s: Settings) => void, +] { + const { value, update } = useLocalStorage(SETTINGS_KEY, defaultSettings); + + // const parsed: Settings = value ?? defaultSettings; + // function updateField<T extends keyof Settings>(k: T, v: Settings[T]) { + // const next = { ...parsed, [k]: v } + // update(next); + // } + return [value, update]; +} + +export function dateFormatForSettings(s: Settings): string { + switch (s.dateFormat) { + case "ymd": return "yyyy/MM/dd" + case "dmy": return "dd/MM/yyyy" + case "mdy": return "MM/dd/yyyy" + } +} + +export function datetimeFormatForSettings(s: Settings): string { + return dateFormatForSettings(s) + " HH:mm:ss" +}
\ No newline at end of file diff --git a/packages/auditor-backoffice-ui/src/hooks/webhooks.ts b/packages/auditor-backoffice-ui/src/hooks/webhooks.ts new file mode 100644 index 000000000..ad6bf96e2 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/hooks/webhooks.ts @@ -0,0 +1,178 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend } from "../declaration.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useBackendInstanceRequest, useMatchMutate } from "./backend.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; + +export function useWebhookAPI(): WebhookAPI { + const mutateAll = useMatchMutate(); + const { request } = useBackendInstanceRequest(); + + const createWebhook = async ( + data: MerchantBackend.Webhooks.WebhookAddDetails, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`/private/webhooks`, { + method: "POST", + data, + }); + await mutateAll(/.*private\/webhooks.*/); + return res; + }; + + const updateWebhook = async ( + webhookId: string, + data: MerchantBackend.Webhooks.WebhookPatchDetails, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`/private/webhooks/${webhookId}`, { + method: "PATCH", + data, + }); + await mutateAll(/.*private\/webhooks.*/); + return res; + }; + + const deleteWebhook = async ( + webhookId: string, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`/private/webhooks/${webhookId}`, { + method: "DELETE", + }); + await mutateAll(/.*private\/webhooks.*/); + return res; + }; + + return { createWebhook, updateWebhook, deleteWebhook }; +} + +export interface WebhookAPI { + createWebhook: ( + data: MerchantBackend.Webhooks.WebhookAddDetails, + ) => Promise<HttpResponseOk<void>>; + updateWebhook: ( + id: string, + data: MerchantBackend.Webhooks.WebhookPatchDetails, + ) => Promise<HttpResponseOk<void>>; + deleteWebhook: (id: string) => Promise<HttpResponseOk<void>>; +} + +export interface InstanceWebhookFilter { + //FIXME: add filter to the webhook list + position?: string; +} + +export function useInstanceWebhooks( + args?: InstanceWebhookFilter, + updatePosition?: (id: string) => void, +): HttpResponsePaginated< + MerchantBackend.Webhooks.WebhookSummaryResponse, + MerchantBackend.ErrorDetail +> { + const { webhookFetcher } = useBackendInstanceRequest(); + + const [pageBefore, setPageBefore] = useState(1); + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + const totalBefore = args?.position ? pageBefore * PAGE_SIZE : 0; + + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.Webhooks.WebhookSummaryResponse>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/webhooks`, args?.position, -totalAfter], webhookFetcher); + + const [lastAfter, setLastAfter] = useState< + HttpResponse< + MerchantBackend.Webhooks.WebhookSummaryResponse, + MerchantBackend.ErrorDetail + > + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + }, [afterData]); + + if (afterError) return afterError.cause; + + const isReachingEnd = + afterData && afterData.data.webhooks.length < totalAfter; + const isReachingStart = true; + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.webhooks.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = `${ + afterData.data.webhooks[afterData.data.webhooks.length - 1].webhook_id + }`; + if (from && updatePosition) updatePosition(from); + } + }, + loadMorePrev: () => { + return; + }, + }; + + const webhooks = !afterData ? [] : (afterData || lastAfter).data.webhooks; + + if (loadingAfter) return { loading: true, data: { webhooks } }; + if (afterData) { + return { ok: true, data: { webhooks }, ...pagination }; + } + return { loading: true }; +} + +export function useWebhookDetails( + webhookId: string, +): HttpResponse< + MerchantBackend.Webhooks.WebhookDetails, + MerchantBackend.ErrorDetail +> { + const { webhookFetcher } = useBackendInstanceRequest(); + + const { data, error, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Webhooks.WebhookDetails>, + RequestError<MerchantBackend.ErrorDetail> + >([`/private/webhooks/${webhookId}`], webhookFetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} |