@alexisapp/leave-core 0.0.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -0
- package/dist/chunk-P5WZALLT.mjs +1633 -0
- package/dist/chunk-R7NHFDIU.mjs +53 -0
- package/dist/chunk-TUQKZ7GW.mjs +207 -0
- package/dist/chunk-VS74AXZ6.mjs +70 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/components/index.mjs +23 -0
- package/dist/domain/index.d.ts +2 -0
- package/dist/domain/index.mjs +0 -0
- package/dist/en-GB-TSTNTOGN.mjs +81 -0
- package/dist/forms/index.d.ts +2 -0
- package/dist/forms/index.mjs +0 -0
- package/dist/graphql-BI4OTV8N.d.ts +1814 -0
- package/dist/hooks/index.d.ts +50 -0
- package/dist/hooks/index.mjs +106 -0
- package/dist/i18n/index.d.ts +18 -0
- package/dist/i18n/index.mjs +16 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.mjs +109 -0
- package/dist/leaveStatusUtils-C26heVdh.d.ts +11 -0
- package/dist/mutations/index.d.ts +2 -0
- package/dist/mutations/index.mjs +0 -0
- package/dist/queries/index.d.ts +489 -0
- package/dist/queries/index.mjs +15 -0
- package/dist/stores/index.d.ts +2 -0
- package/dist/stores/index.mjs +0 -0
- package/dist/utils/index.d.ts +40 -0
- package/dist/utils/index.mjs +53 -0
- package/package.json +94 -0
- package/src/client/createKyInstance.ts +34 -0
- package/src/client/execute.ts +153 -0
- package/src/client/index.ts +4 -0
- package/src/client/initializeClient.ts +48 -0
- package/src/client/resetClient.ts +10 -0
- package/src/client/types.ts +12 -0
- package/src/components/AsyncBoundary.tsx +29 -0
- package/src/components/index.ts +1 -0
- package/src/domain/index.ts +2 -0
- package/src/errors/AuthError.ts +12 -0
- package/src/errors/DomainError.ts +15 -0
- package/src/errors/GraphQLError.ts +16 -0
- package/src/errors/LeaveError.ts +13 -0
- package/src/errors/NetworkError.ts +12 -0
- package/src/errors/classifyError.ts +46 -0
- package/src/errors/errorMessages.ts +69 -0
- package/src/errors/index.ts +13 -0
- package/src/forms/index.ts +2 -0
- package/src/graphql/codegen-gateway.ts +26 -0
- package/src/graphql/codegen-hr-core.ts +31 -0
- package/src/graphql/generated-gateway/fragment-masking.ts +84 -0
- package/src/graphql/generated-gateway/gql.ts +140 -0
- package/src/graphql/generated-gateway/graphql.ts +10828 -0
- package/src/graphql/generated-gateway/index.ts +2 -0
- package/src/graphql/generated-hr-core/fragment-masking.ts +84 -0
- package/src/graphql/generated-hr-core/gql.ts +185 -0
- package/src/graphql/generated-hr-core/graphql.ts +19385 -0
- package/src/graphql/generated-hr-core/index.ts +2 -0
- package/src/graphql/index.ts +278 -0
- package/src/graphql/operations/gateway/leave-change/mutations.graphql +74 -0
- package/src/graphql/operations/gateway/leave-change/queries.graphql +51 -0
- package/src/graphql/operations/gateway/leave-policy-employee-reference/mutations.graphql +26 -0
- package/src/graphql/operations/gateway/leave-self-certified/mutations.graphql +45 -0
- package/src/graphql/operations/gateway/leave-self-certified/queries.graphql +80 -0
- package/src/graphql/operations/gateway/leave-type-code/mutations.graphql +25 -0
- package/src/graphql/operations/gateway/self-certified-policy/mutations.graphql +29 -0
- package/src/graphql/operations/gateway/self-certified-policy/queries.graphql +34 -0
- package/src/graphql/operations/gateway/time-bank/mutations.graphql +23 -0
- package/src/graphql/operations/gateway/time-bank/queries.graphql +5 -0
- package/src/graphql/operations/gateway/time-off-settings/mutations.graphql +19 -0
- package/src/graphql/operations/gateway/time-off-settings/queries.graphql +15 -0
- package/src/graphql/operations/gateway/user/queries.graphql +11 -0
- package/src/graphql/operations/hr-core/balance/mutations.graphql +34 -0
- package/src/graphql/operations/hr-core/balance/queries.graphql +21 -0
- package/src/graphql/operations/hr-core/employee/queries.graphql +27 -0
- package/src/graphql/operations/hr-core/employment/queries.graphql +40 -0
- package/src/graphql/operations/hr-core/file/mutations.graphql +15 -0
- package/src/graphql/operations/hr-core/group/queries.graphql +13 -0
- package/src/graphql/operations/hr-core/leave/mutations.graphql +68 -0
- package/src/graphql/operations/hr-core/leave/queries.graphql +150 -0
- package/src/graphql/operations/hr-core/leave-type/queries.graphql +33 -0
- package/src/graphql/operations/hr-core/member/queries.graphql +58 -0
- package/src/graphql/operations/hr-core/office/queries.graphql +26 -0
- package/src/graphql/operations/hr-core/policy/mutations.graphql +43 -0
- package/src/graphql/operations/hr-core/policy/queries.graphql +46 -0
- package/src/graphql/operations/hr-core/scope/mutations.graphql +19 -0
- package/src/graphql/operations/hr-core/team/queries.graphql +14 -0
- package/src/graphql/operations/hr-core/user/queries.graphql +37 -0
- package/src/graphql/operations/hr-core/work-calendar/queries.graphql +60 -0
- package/src/graphql/operations/hr-core/work-week/queries.graphql +139 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useBalance.ts +58 -0
- package/src/hooks/useCurrentEmployeeId.ts +15 -0
- package/src/hooks/useLeaveList.ts +91 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/instance.ts +52 -0
- package/src/i18n/locale.ts +23 -0
- package/src/i18n/translations/en-GB.json +67 -0
- package/src/index.ts +19 -0
- package/src/mutations/index.ts +2 -0
- package/src/queries/employeeQueryFactory.ts +97 -0
- package/src/queries/index.ts +5 -0
- package/src/queries/leaveQueryFactory.ts +171 -0
- package/src/queries/policyQueryFactory.ts +87 -0
- package/src/queries/settingsQueryFactory.ts +31 -0
- package/src/queries/userQueryFactory.ts +13 -0
- package/src/stores/index.ts +2 -0
- package/src/utils/__tests__/formatDateRangeUtils.test.ts +61 -0
- package/src/utils/__tests__/leaveStatusUtils.test.ts +27 -0
- package/src/utils/__tests__/splitLeaveSectionsUtils.test.ts +71 -0
- package/src/utils/formatDateRangeUtils.ts +71 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/leaveStatusUtils.ts +39 -0
- package/src/utils/splitLeaveSectionsUtils.ts +46 -0
- package/src/utils/typeSafeUtils.ts +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alexisapp/leave-core",
|
|
3
|
+
"version": "0.0.1-beta.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"react-native": "./src/index.ts",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"./queries": {
|
|
12
|
+
"react-native": "./src/queries/index.ts",
|
|
13
|
+
"types": "./dist/queries/index.d.ts",
|
|
14
|
+
"import": "./dist/queries/index.mjs"
|
|
15
|
+
},
|
|
16
|
+
"./mutations": {
|
|
17
|
+
"react-native": "./src/mutations/index.ts",
|
|
18
|
+
"types": "./dist/mutations/index.d.ts",
|
|
19
|
+
"import": "./dist/mutations/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./forms": {
|
|
22
|
+
"react-native": "./src/forms/index.ts",
|
|
23
|
+
"types": "./dist/forms/index.d.ts",
|
|
24
|
+
"import": "./dist/forms/index.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./domain": {
|
|
27
|
+
"react-native": "./src/domain/index.ts",
|
|
28
|
+
"types": "./dist/domain/index.d.ts",
|
|
29
|
+
"import": "./dist/domain/index.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./i18n": {
|
|
32
|
+
"react-native": "./src/i18n/index.ts",
|
|
33
|
+
"types": "./dist/i18n/index.d.ts",
|
|
34
|
+
"import": "./dist/i18n/index.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./components": {
|
|
37
|
+
"react-native": "./src/components/index.ts",
|
|
38
|
+
"types": "./dist/components/index.d.ts",
|
|
39
|
+
"import": "./dist/components/index.mjs"
|
|
40
|
+
},
|
|
41
|
+
"./stores": {
|
|
42
|
+
"react-native": "./src/stores/index.ts",
|
|
43
|
+
"types": "./dist/stores/index.d.ts",
|
|
44
|
+
"import": "./dist/stores/index.mjs"
|
|
45
|
+
},
|
|
46
|
+
"./hooks": {
|
|
47
|
+
"react-native": "./src/hooks/index.ts",
|
|
48
|
+
"types": "./dist/hooks/index.d.ts",
|
|
49
|
+
"import": "./dist/hooks/index.mjs"
|
|
50
|
+
},
|
|
51
|
+
"./utils": {
|
|
52
|
+
"react-native": "./src/utils/index.ts",
|
|
53
|
+
"types": "./dist/utils/index.d.ts",
|
|
54
|
+
"import": "./dist/utils/index.mjs"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"files": [
|
|
58
|
+
"dist",
|
|
59
|
+
"src"
|
|
60
|
+
],
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsup",
|
|
63
|
+
"clean": "rm -rf dist",
|
|
64
|
+
"codegen": "pnpm codegen:gateway && pnpm codegen:hr-core",
|
|
65
|
+
"codegen:gateway": "graphql-codegen --config src/graphql/codegen-gateway.ts",
|
|
66
|
+
"codegen:hr-core": "graphql-codegen --config src/graphql/codegen-hr-core.ts",
|
|
67
|
+
"typecheck": "tsc --noEmit",
|
|
68
|
+
"lint": "eslint src/",
|
|
69
|
+
"test": "vitest run",
|
|
70
|
+
"test:watch": "vitest"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@graphql-codegen/cli": "^6.0.0",
|
|
74
|
+
"@graphql-codegen/client-preset": "^4.0.0",
|
|
75
|
+
"@types/react": "^19.1.0",
|
|
76
|
+
"@parcel/watcher": "^2.5.0",
|
|
77
|
+
"@tanstack/react-query": "^5.90.21",
|
|
78
|
+
"react-error-boundary": "^5.0.0",
|
|
79
|
+
"tsup": "^8.0.0",
|
|
80
|
+
"vitest": "^3.0.0"
|
|
81
|
+
},
|
|
82
|
+
"peerDependencies": {
|
|
83
|
+
"@tanstack/react-query": "^5.0.0",
|
|
84
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
85
|
+
"react-error-boundary": "^5.0.0",
|
|
86
|
+
"zod": "^4.0.0"
|
|
87
|
+
},
|
|
88
|
+
"dependencies": {
|
|
89
|
+
"@graphql-typed-document-node/core": "^3.2.0",
|
|
90
|
+
"graphql": "^16.0.0",
|
|
91
|
+
"i18next": "^23.0.0",
|
|
92
|
+
"ky": "^1.0.0"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import ky, { type KyInstance } from 'ky';
|
|
2
|
+
import type { ClientConfig } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a configured ky instance for GraphQL requests.
|
|
6
|
+
* No prefixUrl — full URLs are passed per-request by execute.gateway() / execute.hrCore().
|
|
7
|
+
* Auth: devToken takes precedence over getToken (§4 Auth Token Flow).
|
|
8
|
+
*/
|
|
9
|
+
export function createKyInstance(config: ClientConfig): KyInstance {
|
|
10
|
+
return ky.create({
|
|
11
|
+
hooks: {
|
|
12
|
+
beforeRequest: [
|
|
13
|
+
async (request) => {
|
|
14
|
+
// Dev override: hardcoded Alexis token, skip getToken()
|
|
15
|
+
if (config.devToken) {
|
|
16
|
+
request.headers.set('Authorization', `Bearer ${config.devToken}`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Mobile prod: Auth0 JWT via host's getToken()
|
|
21
|
+
// Web prod: no getToken — shell BFF proxy handles auth transparently
|
|
22
|
+
if (config.getToken) {
|
|
23
|
+
const token = await config.getToken();
|
|
24
|
+
if (token) {
|
|
25
|
+
request.headers.set('Authorization', `Bearer ${token}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
timeout: 30000,
|
|
32
|
+
retry: { limit: 2, statusCodes: [408, 429, 500, 502, 503, 504] },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { classifyError } from '../errors/classifyError';
|
|
2
|
+
import { AuthError } from '../errors/AuthError';
|
|
3
|
+
import { GraphQLError } from '../errors/GraphQLError';
|
|
4
|
+
import { getKyInstance, getClientConfig } from './initializeClient';
|
|
5
|
+
import type { TypedDocumentString as GatewayDocumentString } from '../graphql/generated-gateway/graphql';
|
|
6
|
+
import type { TypedDocumentString as HrCoreDocumentString } from '../graphql/generated-hr-core/graphql';
|
|
7
|
+
|
|
8
|
+
// ---------- Shared types ----------
|
|
9
|
+
|
|
10
|
+
interface ExecuteBaseProps<TQuery, TVariables> {
|
|
11
|
+
url: string;
|
|
12
|
+
query: TQuery;
|
|
13
|
+
variables: TVariables extends Record<string, never> ? unknown : TVariables | undefined;
|
|
14
|
+
signal?: AbortSignal | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ExecuteProps<TQuery, TVariables> {
|
|
18
|
+
query: TQuery;
|
|
19
|
+
variables: TVariables extends Record<string, never> ? unknown : TVariables | undefined;
|
|
20
|
+
signal?: AbortSignal | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ExecuteGatewayProps<TResult, TVariables> = ExecuteProps<
|
|
24
|
+
GatewayDocumentString<TResult, TVariables>,
|
|
25
|
+
TVariables
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
type ExecuteHrCoreProps<TResult, TVariables> = ExecuteProps<
|
|
29
|
+
HrCoreDocumentString<TResult, TVariables>,
|
|
30
|
+
TVariables
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
// ---------- Core request ----------
|
|
34
|
+
|
|
35
|
+
async function executeBase<TQuery, TVariables, TResult>({
|
|
36
|
+
url,
|
|
37
|
+
query,
|
|
38
|
+
variables,
|
|
39
|
+
signal,
|
|
40
|
+
}: ExecuteBaseProps<TQuery, TVariables>): Promise<TResult> {
|
|
41
|
+
const kyInstance = getKyInstance();
|
|
42
|
+
if (!kyInstance) {
|
|
43
|
+
throw new Error('GraphQL client not initialized. Call initializeClient() first.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const response = await kyInstance
|
|
47
|
+
.post(url, {
|
|
48
|
+
json: { query, variables },
|
|
49
|
+
...(signal != null && { signal }),
|
|
50
|
+
})
|
|
51
|
+
.json<{
|
|
52
|
+
data: TResult;
|
|
53
|
+
errors?: Array<{
|
|
54
|
+
message: string;
|
|
55
|
+
extensions?: Record<string, unknown>;
|
|
56
|
+
}>;
|
|
57
|
+
}>();
|
|
58
|
+
|
|
59
|
+
if (response.errors?.length) {
|
|
60
|
+
throw new GraphQLError(response.errors);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return response.data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------- Endpoint helpers ----------
|
|
67
|
+
|
|
68
|
+
const GATEWAY_PATH = '/v2/graphql';
|
|
69
|
+
const HR_CORE_PATH = '/graphql';
|
|
70
|
+
|
|
71
|
+
function getGatewayUrl(): string {
|
|
72
|
+
const config = getClientConfig();
|
|
73
|
+
if (!config) {
|
|
74
|
+
throw new Error('GraphQL client not initialized. Call initializeClient() first.');
|
|
75
|
+
}
|
|
76
|
+
const base = config.gatewayEndpoint ?? config.endpoint;
|
|
77
|
+
return `${base}${GATEWAY_PATH}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getHrCoreUrl(): string {
|
|
81
|
+
const config = getClientConfig();
|
|
82
|
+
if (!config) {
|
|
83
|
+
throw new Error('GraphQL client not initialized. Call initializeClient() first.');
|
|
84
|
+
}
|
|
85
|
+
return `${config.endpoint}${HR_CORE_PATH}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function executeWithErrorHandling<TResult>(fn: () => Promise<TResult>): Promise<TResult> {
|
|
89
|
+
try {
|
|
90
|
+
return await fn();
|
|
91
|
+
} catch (error: unknown) {
|
|
92
|
+
const classified = classifyError(error);
|
|
93
|
+
|
|
94
|
+
// Auth errors (401, 403): no retry. Token freshness is the host BFF's responsibility.
|
|
95
|
+
// Mobile: calls onAuthError() → host navigates to login.
|
|
96
|
+
// Web: onAuthError is not set (no-op) — shell handles 401 redirect globally.
|
|
97
|
+
if (classified instanceof AuthError) {
|
|
98
|
+
const config = getClientConfig();
|
|
99
|
+
config?.onAuthError?.();
|
|
100
|
+
throw classified;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw classified;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- Public executors ----------
|
|
108
|
+
|
|
109
|
+
const executeGateway = async <TResult, TVariables>({
|
|
110
|
+
query,
|
|
111
|
+
variables,
|
|
112
|
+
signal,
|
|
113
|
+
}: ExecuteGatewayProps<TResult, TVariables>): Promise<TResult> => {
|
|
114
|
+
const url = getGatewayUrl();
|
|
115
|
+
return executeWithErrorHandling(() =>
|
|
116
|
+
executeBase<GatewayDocumentString<TResult, TVariables>, TVariables, TResult>({
|
|
117
|
+
url,
|
|
118
|
+
query,
|
|
119
|
+
variables,
|
|
120
|
+
signal,
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const executeHrCore = async <TResult, TVariables>({
|
|
126
|
+
query,
|
|
127
|
+
variables,
|
|
128
|
+
signal,
|
|
129
|
+
}: ExecuteHrCoreProps<TResult, TVariables>): Promise<TResult> => {
|
|
130
|
+
const url = getHrCoreUrl();
|
|
131
|
+
return executeWithErrorHandling(() =>
|
|
132
|
+
executeBase<HrCoreDocumentString<TResult, TVariables>, TVariables, TResult>({
|
|
133
|
+
url,
|
|
134
|
+
query,
|
|
135
|
+
variables,
|
|
136
|
+
signal,
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Typed GraphQL executors. Classifies all errors at this boundary (§12).
|
|
143
|
+
* Auth errors (401/403): zero retry, immediate onAuthError() callback (§4).
|
|
144
|
+
*
|
|
145
|
+
* - execute.gateway() — new leave service (default for most operations)
|
|
146
|
+
* - execute.hrCore() — legacy HR Core (employee/policy lookups)
|
|
147
|
+
*/
|
|
148
|
+
export const execute = {
|
|
149
|
+
gateway: executeGateway,
|
|
150
|
+
hrCore: executeHrCore,
|
|
151
|
+
} as const;
|
|
152
|
+
|
|
153
|
+
export type { ExecuteGatewayProps, ExecuteHrCoreProps };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { KyInstance } from 'ky';
|
|
2
|
+
import type { ClientConfig } from './types';
|
|
3
|
+
import { createKyInstance } from './createKyInstance';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SINGLETON SCOPE CONSTRAINT (ADR-011):
|
|
7
|
+
* kyInstance and clientConfig are module-scoped. In a Module Federation context with shared
|
|
8
|
+
* singletons, every MF remote that imports @leave/core shares these variables. If two remotes
|
|
9
|
+
* call initializeClient() with different configs, the second call would silently overwrite.
|
|
10
|
+
*
|
|
11
|
+
* Runtime guard: initializeClient() throws if kyInstance already exists and resetClient()
|
|
12
|
+
* was not called first. This turns silent corruption into a loud error.
|
|
13
|
+
*/
|
|
14
|
+
let kyInstance: KyInstance | null = null;
|
|
15
|
+
let clientConfig: ClientConfig | null = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a fresh ky instance. Throws if called twice without resetClient().
|
|
19
|
+
* Called in useEffect with resetClient() in cleanup.
|
|
20
|
+
*/
|
|
21
|
+
export function initializeClient(config: ClientConfig): void {
|
|
22
|
+
if (kyInstance !== null) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'[LeavePackage] initializeClient() called but client already exists. ' +
|
|
25
|
+
'Call resetClient() first. If this error appears in production, it likely means ' +
|
|
26
|
+
'two MF remotes are consuming @leave/core simultaneously — see ADR-011.'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clientConfig = config;
|
|
31
|
+
kyInstance = createKyInstance(config);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Returns current ky instance, or null if not initialized. Internal use only. */
|
|
35
|
+
export function getKyInstance(): KyInstance | null {
|
|
36
|
+
return kyInstance;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Returns current client config, or null if not initialized. Internal use only. */
|
|
40
|
+
export function getClientConfig(): ClientConfig | null {
|
|
41
|
+
return clientConfig;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resets module-scoped singleton. Called by resetClient(). */
|
|
45
|
+
export function _resetInternal(): void {
|
|
46
|
+
kyInstance = null;
|
|
47
|
+
clientConfig = null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { _resetInternal } from './initializeClient';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resets module-scoped singleton. Used in:
|
|
5
|
+
* - useEffect cleanup on unmount (prevents stale closure on MF remount)
|
|
6
|
+
* - afterEach() in tests (prevents cross-test state pollution)
|
|
7
|
+
*/
|
|
8
|
+
export function resetClient(): void {
|
|
9
|
+
_resetInternal();
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ClientConfig {
|
|
2
|
+
/** Base proxy URL. Core appends /v2/graphql (gateway) and /graphql (hrCore) internally. */
|
|
3
|
+
endpoint: string;
|
|
4
|
+
/** Override base URL for gateway only (local dev with split services). Falls back to `endpoint`. */
|
|
5
|
+
gatewayEndpoint?: string | undefined;
|
|
6
|
+
/** Mobile only — returns Auth0 JWT. Web BFF handles auth transparently. */
|
|
7
|
+
getToken?: (() => Promise<string | null>) | undefined;
|
|
8
|
+
/** Mobile only — called on 401/403 before throwing. Shell handles 401 redirect on web. */
|
|
9
|
+
onAuthError?: (() => void) | undefined;
|
|
10
|
+
/** Hardcoded Alexis token for dev. Platform reads from env, core never touches process.env. */
|
|
11
|
+
devToken?: string | undefined;
|
|
12
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { QueryErrorResetBoundary } from '@tanstack/react-query';
|
|
2
|
+
import { type JSX, type ReactNode, Suspense } from 'react';
|
|
3
|
+
import { ErrorBoundary, type ErrorBoundaryProps } from 'react-error-boundary';
|
|
4
|
+
|
|
5
|
+
interface AsyncBoundaryProps {
|
|
6
|
+
suspenseFallback?: ReactNode;
|
|
7
|
+
errorBoundaryProps: ErrorBoundaryProps;
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const AsyncBoundary = ({
|
|
12
|
+
suspenseFallback,
|
|
13
|
+
errorBoundaryProps,
|
|
14
|
+
children,
|
|
15
|
+
}: AsyncBoundaryProps): JSX.Element => (
|
|
16
|
+
<QueryErrorResetBoundary>
|
|
17
|
+
{({ reset }) => (
|
|
18
|
+
<ErrorBoundary
|
|
19
|
+
{...errorBoundaryProps}
|
|
20
|
+
onReset={(...args) => {
|
|
21
|
+
reset();
|
|
22
|
+
errorBoundaryProps.onReset?.(...args);
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<Suspense fallback={suspenseFallback}>{children}</Suspense>
|
|
26
|
+
</ErrorBoundary>
|
|
27
|
+
)}
|
|
28
|
+
</QueryErrorResetBoundary>
|
|
29
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AsyncBoundary } from './AsyncBoundary';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { LeaveError } from './LeaveError';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Authentication/authorization error — 401 or 403.
|
|
5
|
+
* Never retried. Immediately escalated via onAuthError() callback.
|
|
6
|
+
*/
|
|
7
|
+
export class AuthError extends LeaveError {
|
|
8
|
+
constructor(public readonly statusCode: 401 | 403) {
|
|
9
|
+
super('AUTH_ERROR', statusCode === 401 ? 'Session expired' : 'Access denied');
|
|
10
|
+
this.name = 'AuthError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { LeaveError } from './LeaveError';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Business rule violation returned by the server
|
|
5
|
+
* (e.g., insufficient leave balance, blackout period).
|
|
6
|
+
*/
|
|
7
|
+
export class DomainError extends LeaveError {
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly domainCode: string,
|
|
10
|
+
message: string
|
|
11
|
+
) {
|
|
12
|
+
super('DOMAIN_ERROR', message);
|
|
13
|
+
this.name = 'DomainError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { LeaveError } from './LeaveError';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server returned errors[] in the GraphQL response body.
|
|
5
|
+
*/
|
|
6
|
+
export class GraphQLError extends LeaveError {
|
|
7
|
+
constructor(
|
|
8
|
+
public readonly errors: Array<{
|
|
9
|
+
message: string;
|
|
10
|
+
extensions?: Record<string, unknown>;
|
|
11
|
+
}>
|
|
12
|
+
) {
|
|
13
|
+
super('GRAPHQL_ERROR', errors[0]?.message ?? 'Unknown server error');
|
|
14
|
+
this.name = 'GraphQLError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base error class for all leave management errors.
|
|
3
|
+
* All domain-specific errors extend this class.
|
|
4
|
+
*/
|
|
5
|
+
export class LeaveError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
public readonly code: string,
|
|
8
|
+
message: string
|
|
9
|
+
) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'LeaveError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { LeaveError } from './LeaveError';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch failed, timeout, or no connectivity.
|
|
5
|
+
* Also used for non-auth HTTP errors that reach classifyError after ky retries are exhausted.
|
|
6
|
+
*/
|
|
7
|
+
export class NetworkError extends LeaveError {
|
|
8
|
+
constructor(public readonly cause: unknown) {
|
|
9
|
+
super('NETWORK_ERROR', 'Unable to reach the server');
|
|
10
|
+
this.name = 'NetworkError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { HTTPError } from 'ky';
|
|
2
|
+
import { LeaveError } from './LeaveError';
|
|
3
|
+
import { AuthError } from './AuthError';
|
|
4
|
+
import { NetworkError } from './NetworkError';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Classifies an unknown error into a LeaveError subclass.
|
|
8
|
+
* Idempotent — if the error is already a LeaveError, passes through unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Classification order:
|
|
11
|
+
* 1. LeaveError passthrough (idempotent)
|
|
12
|
+
* 2. AbortError → ABORT_ERROR (no-op in UI — no toast, no error boundary)
|
|
13
|
+
* 3. ky HTTPError 401/403 → AuthError
|
|
14
|
+
* 4. ky HTTPError other → NetworkError (retries already exhausted by ky)
|
|
15
|
+
* 5. TypeError → NetworkError (fetch throws TypeError on DNS failure, CORS, network-down)
|
|
16
|
+
* 6. Fallback → UNKNOWN_ERROR
|
|
17
|
+
*/
|
|
18
|
+
export function classifyError(error: unknown): LeaveError {
|
|
19
|
+
// 1. Idempotent passthrough
|
|
20
|
+
if (error instanceof LeaveError) {
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 2. AbortError — host unmounted mid-request (§12b)
|
|
25
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
26
|
+
return new LeaveError('ABORT_ERROR', 'Request aborted');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 3–4. ky HTTPError
|
|
30
|
+
if (error instanceof HTTPError) {
|
|
31
|
+
const status = error.response.status;
|
|
32
|
+
if (status === 401 || status === 403) {
|
|
33
|
+
return new AuthError(status);
|
|
34
|
+
}
|
|
35
|
+
return new NetworkError(error);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 5. TypeError — fetch failed (DNS, CORS, network-down)
|
|
39
|
+
if (error instanceof TypeError) {
|
|
40
|
+
return new NetworkError(error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 6. Fallback
|
|
44
|
+
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
|
|
45
|
+
return new LeaveError('UNKNOWN_ERROR', message);
|
|
46
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { HTTPError } from 'ky';
|
|
2
|
+
import { AuthError } from './AuthError';
|
|
3
|
+
import { DomainError } from './DomainError';
|
|
4
|
+
import { NetworkError } from './NetworkError';
|
|
5
|
+
import { GraphQLError } from './GraphQLError';
|
|
6
|
+
import { getLeaveI18n } from '../i18n';
|
|
7
|
+
|
|
8
|
+
export enum ErrorCode {
|
|
9
|
+
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
|
|
10
|
+
CONNECTION_FAILURE = 'CONNECTION_FAILURE',
|
|
11
|
+
BAD_REQUEST = 'BAD_REQUEST',
|
|
12
|
+
NOT_FOUND = 'NOT_FOUND',
|
|
13
|
+
SYSTEM_FAILURE = 'SYSTEM_FAILURE',
|
|
14
|
+
UNAUTHORIZED = 'UNAUTHORIZED',
|
|
15
|
+
FORBIDDEN = 'FORBIDDEN',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const httpStatusToErrorCode: Record<number, ErrorCode> = {
|
|
19
|
+
400: ErrorCode.BAD_REQUEST,
|
|
20
|
+
401: ErrorCode.UNAUTHORIZED,
|
|
21
|
+
403: ErrorCode.FORBIDDEN,
|
|
22
|
+
404: ErrorCode.NOT_FOUND,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function getErrorCode(error: unknown): ErrorCode {
|
|
26
|
+
if (error instanceof AuthError) {
|
|
27
|
+
return error.statusCode === 401 ? ErrorCode.UNAUTHORIZED : ErrorCode.FORBIDDEN;
|
|
28
|
+
}
|
|
29
|
+
if (error instanceof NetworkError) {
|
|
30
|
+
return ErrorCode.CONNECTION_FAILURE;
|
|
31
|
+
}
|
|
32
|
+
if (error instanceof GraphQLError) {
|
|
33
|
+
return ErrorCode.SYSTEM_FAILURE;
|
|
34
|
+
}
|
|
35
|
+
// ky HTTPError (has response property with status)
|
|
36
|
+
if (error instanceof Error && 'response' in error) {
|
|
37
|
+
const status = (error as { response?: { status?: number } }).response?.status;
|
|
38
|
+
if (status != null) {
|
|
39
|
+
const mapped = httpStatusToErrorCode[status];
|
|
40
|
+
if (mapped != null) return mapped;
|
|
41
|
+
if (status >= 500) return ErrorCode.SYSTEM_FAILURE;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return ErrorCode.UNEXPECTED_ERROR;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isHttpError(error: unknown): error is HTTPError {
|
|
48
|
+
return error instanceof HTTPError;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isRetryable(error: unknown): boolean {
|
|
52
|
+
if (error instanceof NetworkError) return true;
|
|
53
|
+
if (error instanceof GraphQLError) return true;
|
|
54
|
+
if (error instanceof AuthError) return false;
|
|
55
|
+
if (error instanceof DomainError) return false;
|
|
56
|
+
if (error instanceof HTTPError) {
|
|
57
|
+
return error.response.status >= 500;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getErrorMessage(error: unknown): { title: string; description: string } {
|
|
63
|
+
const code = getErrorCode(error);
|
|
64
|
+
const t = getLeaveI18n().t;
|
|
65
|
+
return {
|
|
66
|
+
title: t(`errors.${code}.title`),
|
|
67
|
+
description: t(`errors.${code}.description`),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { LeaveError } from './LeaveError';
|
|
2
|
+
export { AuthError } from './AuthError';
|
|
3
|
+
export { GraphQLError } from './GraphQLError';
|
|
4
|
+
export { NetworkError } from './NetworkError';
|
|
5
|
+
export { DomainError } from './DomainError';
|
|
6
|
+
export { classifyError } from './classifyError';
|
|
7
|
+
export {
|
|
8
|
+
ErrorCode,
|
|
9
|
+
getErrorCode,
|
|
10
|
+
isHttpError,
|
|
11
|
+
isRetryable,
|
|
12
|
+
getErrorMessage,
|
|
13
|
+
} from './errorMessages';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CodegenConfig } from '@graphql-codegen/cli';
|
|
2
|
+
|
|
3
|
+
const config: CodegenConfig = {
|
|
4
|
+
overwrite: true,
|
|
5
|
+
schema: 'https://api-2.dev-alexishr.io/v2/graphql',
|
|
6
|
+
documents: ['./src/graphql/operations/gateway/**/*.graphql'],
|
|
7
|
+
ignoreNoDocuments: false,
|
|
8
|
+
generates: {
|
|
9
|
+
'./src/graphql/generated-gateway/': {
|
|
10
|
+
preset: 'client',
|
|
11
|
+
config: {
|
|
12
|
+
documentMode: 'string',
|
|
13
|
+
strictScalars: true,
|
|
14
|
+
scalars: {
|
|
15
|
+
ID: { input: 'string', output: 'string' },
|
|
16
|
+
DateTime: 'string',
|
|
17
|
+
JSON: 'Record<string, unknown>',
|
|
18
|
+
JSONObject: 'Record<string, unknown>',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
hooks: { afterOneFileWrite: ['prettier --write'] },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default config;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CodegenConfig } from '@graphql-codegen/cli';
|
|
2
|
+
|
|
3
|
+
const config: CodegenConfig = {
|
|
4
|
+
overwrite: true,
|
|
5
|
+
schema: 'https://api-2.dev-alexishr.io/graphql',
|
|
6
|
+
documents: ['./src/graphql/operations/hr-core/**/*.graphql'],
|
|
7
|
+
ignoreNoDocuments: false,
|
|
8
|
+
generates: {
|
|
9
|
+
'./src/graphql/generated-hr-core/': {
|
|
10
|
+
preset: 'client',
|
|
11
|
+
config: {
|
|
12
|
+
documentMode: 'string',
|
|
13
|
+
strictScalars: true,
|
|
14
|
+
scalars: {
|
|
15
|
+
ID: { input: 'string', output: 'string' },
|
|
16
|
+
DateTime: 'string',
|
|
17
|
+
JSON: 'Record<string, unknown>',
|
|
18
|
+
JSONObject: 'Record<string, unknown>',
|
|
19
|
+
ObjectId: 'string',
|
|
20
|
+
Email: 'string',
|
|
21
|
+
Date: 'string',
|
|
22
|
+
Object: 'Record<string, unknown>',
|
|
23
|
+
Upload: 'File',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
hooks: { afterOneFileWrite: ['prettier --write'] },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default config;
|