@titus-system/syncdesk 0.3.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/.editorconfig ADDED
@@ -0,0 +1,53 @@
1
+ # EditorConfig is awesome: http://EditorConfig.org
2
+ # 1st version comes from a Gist by @avandrevitor [here](https://gist.github.com/avandrevitor/b917770334af3a43cf8c489f93287a84)
3
+ #
4
+ # Extended version is available in [WesleyGoncalves' GitHub Gist](https://gist.githubusercontent.com/WesleyGoncalves/ae64d95f663a15d8df455d1f0e5f7687)
5
+ #
6
+ # Be aware that:
7
+ # > Comments should go in individual lines, **we are not sure whether appending comments may cause issues**.
8
+ # Emphasis added. [link](https://github.com/editorconfig/editorconfig/wiki/Syntax)
9
+ #
10
+ #
11
+ # You can download this file
12
+ # directly to your project from the command-line
13
+ # curl -O https://gist.githubusercontent.com/WesleyGoncalves/ae64d95f663a15d8df455d1f0e5f7687/raw/.editorconfig
14
+
15
+ # top-most EditorConfig file
16
+ root = true
17
+
18
+ # All PHP files MUST use the Unix LF (linefeed) line ending.
19
+ # Code MUST use an indent of 4 spaces, and MUST NOT use tabs for indenting.
20
+ # All PHP files MUST end with a single blank line.
21
+ # There MUST NOT be trailing whitespace at the end of non-blank lines.
22
+ [*]
23
+ charset = utf-8
24
+ end_of_line = lf
25
+ insert_final_newline = true
26
+ trim_trailing_whitespace = true
27
+
28
+ # PHP-Files | Python | Java
29
+ [*.{php,py,java}]
30
+ indent_style = space
31
+ indent_size = 4
32
+
33
+ [composer.json,docker-compose.yml]
34
+ indent_style = space
35
+ indent_size = 4
36
+
37
+ # **NOT IMPLEMENTED BY editorConfig** PHP-Files. See [this for further information](https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#ideas-for-domain-specific-properties)
38
+ [*.php]
39
+ curly_bracket_next_line = true
40
+ indent_brace_style = K&R
41
+ spaces_around_operators = true
42
+ # block_comment_start = '/*'
43
+ # block_comment_end = '*/'
44
+ # line_comment = '//'
45
+
46
+ # BLADE-Files | MD | HTML | LESS | SASS | CSS | JS | JSX | TS | TSX | JSON | ReST | YAML | TXT
47
+ [*.{blade.php,md,html,less,sass,scss,css,js{x,},ts{x,},json,rst,y{a,}ml,txt}]
48
+ indent_style = space
49
+ indent_size = 2
50
+
51
+ [{package.json}]
52
+ indent_style = space
53
+ indent_size = 2
@@ -0,0 +1,17 @@
1
+ name: Notify Main Repo
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ dispatch:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Repository Dispatch
13
+ uses: peter-evans/repository-dispatch@v3
14
+ with:
15
+ token: ${{ secrets.PAT_TOKEN }}
16
+ repository: Titus-System/SyncDesk
17
+ event-type: submodule_update
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # SyncDesk Library
2
+
3
+ Biblioteca de integração do [SyncDesk](https://github.com/Titus-System/SyncDesk).
4
+
5
+ Essa biblioteca fornece recursos para as aplicações frontend do sistema SyncDesk, permitindo a comunicação eficiente e segura entre os componentes do sistema. Ela inclui funcionalidades de autenticação, gerenciamento de requisições para o backend e manipulação de dados, facilitando a construção de interfaces de usuário responsivas e interativas.
6
+
7
+ ## Installation
8
+
9
+ ### Install from npm registry
10
+
11
+ ```sh
12
+ npm install @titus-system/syncdesk
13
+ ```
14
+
15
+ ### Install locally
16
+
17
+ ```sh
18
+ npm run build
19
+ npm pack
20
+ ```
21
+
22
+ It creates a compressed tarball file in your folder. You can install it in your project with:
23
+
24
+ ```sh
25
+ npm install /path/to/your/library-1.0.0.tgz
26
+ ```
27
+
28
+ ## Publish new version to npm registry
29
+
30
+ ```sh
31
+ # You may have to login to npm registry first with `npm login`
32
+ npm login
33
+
34
+ npm run build && npm publish
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Auth
40
+
41
+ If the token expires, the auth module will automatically attempt to refresh it using the refresh token. If the refresh token is also expired or invalid, the `config.onUnauthorized` callback will be triggered, allowing you to handle the unauthorized state (e.g., redirecting to the login page).
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@titus-system/syncdesk",
3
+ "version": "0.3.1",
4
+ "description": "",
5
+ "keywords": [],
6
+ "author": "",
7
+ "license": "",
8
+ "bugs": {
9
+ "url": "https://github.com/Titus-System/syncdesk-library/issues"
10
+ },
11
+ "homepage": "https://github.com/Titus-System/syncdesk-library#readme",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Titus-System/syncdesk-library.git"
15
+ },
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "require": "./dist/index.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "clean": "rm -rf dist",
27
+ "build": "npm run clean && tsc",
28
+ "dev": "tsc --watch"
29
+ },
30
+ "peerDependencies": {
31
+ "@tanstack/react-query": "^5.95.2",
32
+ "react": "^19.2.4"
33
+ },
34
+ "dependencies": {
35
+ "axios": "^1.13.6",
36
+ "react-use-websocket": "^4.13.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^19.2.14",
40
+ "typescript": "^6.0.2"
41
+ }
42
+ }
@@ -0,0 +1,135 @@
1
+ import axios, { InternalAxiosRequestConfig } from "axios";
2
+ import { config, LibraryConfig } from "../config";
3
+
4
+ export const apiClient = axios.create();
5
+
6
+ /**
7
+ * Configure the library with user-provided settings.
8
+ */
9
+ export const configureLibrary = (userConfig: LibraryConfig) => {
10
+ // Overwrite the default internal config with the user's settings
11
+ Object.assign(config, userConfig);
12
+
13
+ apiClient.defaults.baseURL = config.baseURL;
14
+ apiClient.defaults.timeout = config.timeout;
15
+ };
16
+
17
+ // INTERCEPTORS
18
+
19
+ /**
20
+ * Request Interceptor.
21
+ * Automatically adds the access token to the Authorization header of every request if it's available.
22
+ */
23
+ apiClient.interceptors.request.use(
24
+ async (reqConfig) => {
25
+ const token = await config.getAccessToken();
26
+ if (token && reqConfig.headers) {
27
+ reqConfig.headers.Authorization = `Bearer ${token}`;
28
+ }
29
+ return reqConfig;
30
+ },
31
+ (error) => Promise.reject(error),
32
+ );
33
+
34
+ /**
35
+ * Response Interceptor.
36
+ */
37
+ let isRefreshing = false; // Flag to prevent multiple simultaneous refresh attempts
38
+ let failedQueue: Array<{
39
+ // Queue to hold requests that come in while we're refreshing tokens
40
+ resolve: (token: string) => void;
41
+ reject: (error: any) => void;
42
+ }> = [];
43
+ /** Processes the queue of failed requests after token refresh. */
44
+ const processQueue = (error: any, token: string | null = null) => {
45
+ failedQueue.forEach((promise) => {
46
+ if (error) {
47
+ promise.reject(error);
48
+ } else {
49
+ promise.resolve(token as string);
50
+ }
51
+ });
52
+ failedQueue = [];
53
+ };
54
+
55
+ /**
56
+ * This interceptor watches for 401 responses. If it sees one, it tries to get a new access token using
57
+ * the refresh token.
58
+ *
59
+ * Meanwhile, it queues up any new requests that also fail with 401, and once it gets a new token,
60
+ * it retries all the failed requests with the new token. If the refresh token is also expired/invalid,
61
+ * it calls the onUnauthorized callback to let the app know they need to log in again.
62
+ */
63
+ apiClient.interceptors.response.use(
64
+ (response) => response, // success
65
+ async (error) => {
66
+ const originalRequest = error.config as InternalAxiosRequestConfig & {
67
+ _retry?: boolean;
68
+ };
69
+
70
+ // if 401 and not retried
71
+ if (error.response?.status === 401 && !originalRequest._retry) {
72
+ if (
73
+ originalRequest.url?.includes("/refresh") ||
74
+ originalRequest.url?.includes("/login")
75
+ ) {
76
+ config.onUnauthorized();
77
+ return Promise.reject(error);
78
+ }
79
+
80
+ // If another request is currently refreshing the token, pause this request and add to queue
81
+ if (isRefreshing) {
82
+ return (
83
+ new Promise((resolve, reject) => {
84
+ failedQueue.push({ resolve, reject });
85
+ })
86
+ .then((token) => {
87
+ // on success, add the new token to the request header and return the client
88
+ originalRequest.headers.Authorization = `Bearer ${token}`;
89
+ return apiClient(originalRequest);
90
+ })
91
+ // on error, reject the request
92
+ .catch((err) => Promise.reject(err))
93
+ );
94
+ }
95
+
96
+ // Mark that we are starting the refresh process
97
+ originalRequest._retry = true;
98
+ isRefreshing = true;
99
+
100
+ try {
101
+ const refreshToken = await config.getRefreshToken();
102
+
103
+ if (!refreshToken) {
104
+ throw new Error("No refresh token available.");
105
+ }
106
+
107
+ // Do a standard axios call to get the new token, so the interceptors are not called again.
108
+ const refreshResponse = await axios.post(`${config.baseURL}/refresh`, {
109
+ refresh_token: refreshToken,
110
+ });
111
+
112
+ const newAccessToken = refreshResponse.data.data.access_token;
113
+ const newRefreshToken = refreshResponse.data.data.refresh_token;
114
+
115
+ await config.onTokensRefreshed(newAccessToken, newRefreshToken);
116
+
117
+ // Resume all the other paused requests with the new token
118
+ processQueue(null, newAccessToken);
119
+
120
+ // Retry the original request that failed
121
+ originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
122
+ return apiClient(originalRequest);
123
+ } catch (refreshError) {
124
+ // If the refresh token is completely expired, kill the queue and log them out
125
+ processQueue(refreshError, null);
126
+ config.onUnauthorized();
127
+ return Promise.reject(refreshError);
128
+ } finally {
129
+ isRefreshing = false;
130
+ }
131
+ }
132
+
133
+ return Promise.reject(error);
134
+ },
135
+ );
@@ -0,0 +1,2 @@
1
+ export { apiClient, configureLibrary } from "./client";
2
+ export type { ApiResponse } from "./typings";
@@ -0,0 +1,3 @@
1
+ export interface ApiResponse<T> {
2
+ data: T;
3
+ }
@@ -0,0 +1,95 @@
1
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { apiClient, ApiResponse } from "../../api";
3
+ import {
4
+ LoginResponse,
5
+ RegisterUserRequest,
6
+ UserCreatedResponse,
7
+ UserLoginRequest,
8
+ UserWithRoles,
9
+ } from "../types/auth";
10
+
11
+ const PATH = "auth";
12
+
13
+ /**
14
+ * Get the currently authenticated user's profile.
15
+ */
16
+ export const useGetMe = () => {
17
+ return useQuery({
18
+ queryKey: ["me"],
19
+ queryFn: async (): Promise<UserWithRoles> => {
20
+ const response = await apiClient.get<ApiResponse<UserWithRoles>>(
21
+ `${PATH}/me`,
22
+ );
23
+
24
+ return response.data.data;
25
+ },
26
+ // if the user isn't logged in (401)
27
+ retry: false,
28
+ });
29
+ };
30
+
31
+ /**
32
+ * Log in a user.
33
+ *
34
+ * Refresh tokens are handled automatically using Interceptors.
35
+ */
36
+ export const useLogin = () => {
37
+ const queryClient = useQueryClient();
38
+
39
+ return useMutation({
40
+ mutationFn: async (
41
+ credentials: UserLoginRequest,
42
+ ): Promise<LoginResponse> => {
43
+ const response = await apiClient.post<ApiResponse<LoginResponse>>(
44
+ `${PATH}/login`,
45
+ credentials,
46
+ );
47
+ return response.data.data;
48
+ },
49
+ onSuccess: () => {
50
+ // After a successful login, fetch the new user's profile
51
+ queryClient.invalidateQueries({ queryKey: ["me"] });
52
+ },
53
+ });
54
+ };
55
+
56
+ /**
57
+ * Register a new user.
58
+ */
59
+ export const useRegister = () => {
60
+ const queryClient = useQueryClient();
61
+
62
+ return useMutation({
63
+ mutationFn: async (
64
+ userData: RegisterUserRequest,
65
+ ): Promise<UserCreatedResponse> => {
66
+ const response = await apiClient.post<ApiResponse<UserCreatedResponse>>(
67
+ `${PATH}/register`,
68
+ userData,
69
+ );
70
+ return response.data.data;
71
+ },
72
+ onSuccess: () => {
73
+ // Registration also logs them in (returns tokens), so fetch their profile
74
+ queryClient.invalidateQueries({ queryKey: ["me"] });
75
+ },
76
+ });
77
+ };
78
+
79
+ /**
80
+ * Log out the current user.
81
+ */
82
+ export const useLogout = () => {
83
+ const queryClient = useQueryClient();
84
+
85
+ return useMutation({
86
+ mutationFn: async (): Promise<void> => {
87
+ await apiClient.post(`${PATH}/logout`);
88
+ },
89
+ onSuccess: () => {
90
+ // clear cache
91
+ queryClient.setQueryData(["me"], null);
92
+ queryClient.clear();
93
+ },
94
+ });
95
+ };
@@ -0,0 +1,12 @@
1
+ export { useGetMe, useLogin, useRegister, useLogout } from "./hooks/useAuth";
2
+
3
+ export type {
4
+ OAuthProvider,
5
+ LoginResponse,
6
+ UserCreatedResponse,
7
+ UserLoginRequest,
8
+ RegisterUserRequest,
9
+ UserWithRoles,
10
+ } from "./types/auth";
11
+
12
+ export type { Status, Session } from "./types/session";
@@ -0,0 +1,35 @@
1
+ export type OAuthProvider = "local" | "google" | "microsoft";
2
+
3
+ export interface LoginResponse {
4
+ access_token: string;
5
+ refresh_token: string;
6
+ }
7
+
8
+ export interface UserCreatedResponse {
9
+ id: string;
10
+ email: string;
11
+ username: string;
12
+ access_token: string;
13
+ refresh_token: string;
14
+ }
15
+
16
+ export interface UserLoginRequest {
17
+ email: string;
18
+ password: string;
19
+ }
20
+
21
+ export interface RegisterUserRequest {
22
+ email: string;
23
+ name: string;
24
+ username: string;
25
+ password: string;
26
+ }
27
+
28
+ // I am making a safe assumption for your /me route based on "user_with_roles"
29
+ export interface UserWithRoles {
30
+ id: string;
31
+ email: string;
32
+ username: string;
33
+ name?: string | null;
34
+ roles: string[];
35
+ }
@@ -0,0 +1,12 @@
1
+ export type Status = "active" | "expired" | "invalid" | "revoked";
2
+
3
+ export interface Session {
4
+ id: string;
5
+ user_id: string;
6
+ refresh_token_hash: string;
7
+ status: Status;
8
+ device_info: Record<string, unknown>;
9
+ expires_at: Date | string;
10
+ last_used_at: Date | string;
11
+ revoked_at: Date | string;
12
+ }
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ export interface LibraryConfig {
2
+ /** API URL */
3
+ baseURL: string;
4
+ timeout?: number;
5
+
6
+ /* Get current access token */
7
+ getAccessToken: () => string | null | Promise<string | null>;
8
+
9
+ /** Get the refresh token when it needs a new one */
10
+ getRefreshToken: () => string | null | Promise<string | null>;
11
+
12
+ /** Callback fired when the library successfully gets new tokens from the backend */
13
+ onTokensRefreshed: (
14
+ accessToken: string,
15
+ refreshToken: string,
16
+ ) => void | Promise<void>;
17
+
18
+ /** Fired if the user is not authenticated and refresh token fails. */
19
+ onUnauthorized: () => void;
20
+ }
21
+
22
+ export const config: Required<LibraryConfig> = {
23
+ baseURL: "",
24
+ timeout: 10000,
25
+ getAccessToken: () => null,
26
+ getRefreshToken: () => null,
27
+ onTokensRefreshed: () => {},
28
+ onUnauthorized: () => {
29
+ console.warn("Unauthorized error caught. No handler provided.");
30
+ },
31
+ };
@@ -0,0 +1,48 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { apiClient, ApiResponse } from "../../api";
3
+ import type {
4
+ PingResponse,
5
+ HealthResponse,
6
+ ReadyResponse,
7
+ } from "../types/health";
8
+
9
+ /**
10
+ * Ping the server to check for basic connectivity.
11
+ */
12
+ export const usePing = () => {
13
+ return useQuery({
14
+ queryKey: ["health", "ping"],
15
+ queryFn: async (): Promise<PingResponse> => {
16
+ const response = await apiClient.get<ApiResponse<PingResponse>>("/ping");
17
+ return response.data.data;
18
+ },
19
+ });
20
+ };
21
+
22
+ /**
23
+ * Check the detailed health of the server and its dependencies (e.g. databases).
24
+ */
25
+ export const useHealth = () => {
26
+ return useQuery({
27
+ queryKey: ["health", "status"],
28
+ queryFn: async (): Promise<HealthResponse> => {
29
+ const response =
30
+ await apiClient.get<ApiResponse<HealthResponse>>("/health");
31
+ return response.data.data;
32
+ },
33
+ });
34
+ };
35
+
36
+ /**
37
+ * Check if the server is ready to accept traffic.
38
+ */
39
+ export const useReady = () => {
40
+ return useQuery({
41
+ queryKey: ["health", "ready"],
42
+ queryFn: async (): Promise<ReadyResponse> => {
43
+ const response =
44
+ await apiClient.get<ApiResponse<ReadyResponse>>("/ready");
45
+ return response.data.data;
46
+ },
47
+ });
48
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./hooks/useHealth";
2
+ export * from "./types/health";
@@ -0,0 +1,10 @@
1
+ export interface PingResponse {
2
+ message: string;
3
+ }
4
+
5
+ export interface HealthResponse {
6
+ postgres_status: "connected" | "degraded";
7
+ mongo_status: "connected" | "degraded";
8
+ }
9
+
10
+ export interface ReadyResponse {}
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./auth";
2
+ export * from "./api";
3
+ export * from "./users";
4
+ export * from "./roles";
5
+ export * from "./permissions";
6
+ export * from "./live_chat";
7
+ export * from "./health";
8
+
9
+ export { config } from "./config";
10
+ export type { LibraryConfig } from "./config";
@@ -0,0 +1,103 @@
1
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { apiClient, ApiResponse } from "../../api";
3
+ import {
4
+ Conversation,
5
+ CreateConversationDTO,
6
+ PaginatedMessages,
7
+ } from "../types/live_chat";
8
+
9
+ const PATH = "/conversations";
10
+
11
+ /**
12
+ * Get all conversations recursively mapping to a ticket.
13
+ * @param ticket_id
14
+ */
15
+ export const useGetConversations = (ticket_id: string) => {
16
+ return useQuery({
17
+ queryKey: ["conversations", "ticket", ticket_id],
18
+ queryFn: async (): Promise<Conversation[]> => {
19
+ const response = await apiClient.get<ApiResponse<Conversation[]>>(
20
+ `${PATH}/ticket/${ticket_id}`,
21
+ );
22
+ return response.data.data;
23
+ },
24
+ enabled: !!ticket_id,
25
+ });
26
+ };
27
+
28
+ /**
29
+ * Get paginated messages for a ticket.
30
+ * @param ticket_id
31
+ * @param page
32
+ * @param limit
33
+ */
34
+ export const useGetPaginatedMessages = (
35
+ ticket_id: string,
36
+ page = 1,
37
+ limit = 10,
38
+ ) => {
39
+ return useQuery({
40
+ queryKey: [
41
+ "conversations",
42
+ "ticket",
43
+ ticket_id,
44
+ "messages",
45
+ { page, limit },
46
+ ],
47
+ queryFn: async (): Promise<PaginatedMessages> => {
48
+ const response = await apiClient.get<ApiResponse<PaginatedMessages>>(
49
+ `${PATH}/ticket/${ticket_id}/messages`,
50
+ { params: { page, limit } },
51
+ );
52
+ return response.data.data;
53
+ },
54
+ enabled: !!ticket_id,
55
+ });
56
+ };
57
+
58
+ /**
59
+ * Create a new conversation mapping to a ticket.
60
+ */
61
+ export const useCreateConversation = () => {
62
+ const queryClient = useQueryClient();
63
+
64
+ return useMutation({
65
+ mutationFn: async (dto: CreateConversationDTO): Promise<Conversation> => {
66
+ const response = await apiClient.post<ApiResponse<Conversation>>(
67
+ PATH,
68
+ dto,
69
+ );
70
+ return response.data.data;
71
+ },
72
+ onSuccess: (_, variables) => {
73
+ queryClient.invalidateQueries({
74
+ queryKey: ["conversations", "ticket", variables.ticket_id],
75
+ });
76
+ },
77
+ });
78
+ };
79
+
80
+ /**
81
+ * Assign an agent to a conversation.
82
+ */
83
+ export const useSetConversationAgent = () => {
84
+ const queryClient = useQueryClient();
85
+
86
+ return useMutation({
87
+ mutationFn: async ({
88
+ chat_id,
89
+ agent_id,
90
+ }: {
91
+ chat_id: string;
92
+ agent_id: string;
93
+ }): Promise<void> => {
94
+ await apiClient.patch(`${PATH}/${chat_id}/set-agent/${agent_id}`);
95
+ },
96
+ // We invalidate the ticket conversations here.
97
+ // To do so effectively, you may need the ticket_id, but the mutation only receives the chat_id.
98
+ // If ticket list invalidation is required, invalidate the whole 'conversations' key.
99
+ onSuccess: () => {
100
+ queryClient.invalidateQueries({ queryKey: ["conversations"] });
101
+ },
102
+ });
103
+ };