Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ import { RealTimeTemplates } from '@/hoc/RealtimeTemplates'
export const maxDuration = 300

export async function getAllWorkflowStates(token: string): Promise<WorkflowStateResponse[]> {
const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, {
next: { tags: ['getAllWorkflowStates'] },
})
const data = await fetchWithErrorHandler<{ workflowStates: WorkflowStateResponse[] }>(
`${apiUrl}/api/workflow-states?token=${token}`,
{
next: { tags: ['getAllWorkflowStates'] },
},
)

const data = await res.json()
return data.workflowStates
}

Expand Down Expand Up @@ -67,10 +69,9 @@ export async function getWorkspace(token: string): Promise<WorkspaceResponse> {
}

export async function getViewSettings(token: string): Promise<CreateViewSettingsDTO> {
const res = await fetch(`${apiUrl}/api/view-settings?token=${token}`, {
const resp = await fetchWithErrorHandler<CreateViewSettingsDTO>(`${apiUrl}/api/view-settings?token=${token}`, {
next: { tags: ['getViewSettings'] },
})
const resp = await res.json()

return resp
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/_fetchers/AllTasksFetcher.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
export const fetchCache = 'force-no-store'

import { apiUrl } from '@/config'
import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler'
import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate'
import { TaskResponse } from '@/types/dto/tasks.dto'
import { PropsWithToken } from '@/types/interfaces'
import { PropsWithChildren } from 'react'

const getAllAccessibleTasks = async (token: string): Promise<TaskResponse[]> => {
const res = await fetch(`${apiUrl}/api/tasks?token=${token}&all=1`)
const { tasks } = await res.json()
const { tasks } = await fetchWithErrorHandler<{ tasks: TaskResponse[] }>(`${apiUrl}/api/tasks?token=${token}&all=1`)
return tasks
}

Expand Down
28 changes: 14 additions & 14 deletions src/app/_fetchers/AssigneeFetcher.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const fetchCache = 'force-no-store'

import { AssigneeCacheSetter } from '@/app/_cache/AssigneeCacheSetter'
import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler'
import { apiUrl } from '@/config'
import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users'
import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate'
Expand All @@ -9,9 +10,6 @@ import { TaskResponse } from '@/types/dto/tasks.dto'
import { CreateViewSettingsDTO } from '@/types/dto/viewSettings.dto'
import { IAssignee, PropsWithToken, UserType } from '@/types/interfaces'
import { addTypeToAssignee } from '@/utils/addTypeToAssignee'
import fetchRetry from 'fetch-retry'

const fetchWithRetry = fetchRetry(globalThis.fetch)

interface AssigneeFetcherProps extends PropsWithToken {
viewSettings?: CreateViewSettingsDTO
Expand All @@ -23,21 +21,23 @@ interface AssigneeFetcherProps extends PropsWithToken {

const fetchAssignee = async (token: string, userType?: UserType, isPreview?: boolean): Promise<IAssignee> => {
if (userType === UserType.CLIENT_USER && !isPreview) {
const res = await fetchWithRetry(`${apiUrl}/api/users/client?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, {
next: { tags: ['getAssigneeList'] },
retries: 3,
retryDelay: 100,
})

const data = await res.json()
const data = await fetchWithErrorHandler<{ clients: IAssignee }>(
`${apiUrl}/api/users/client?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`,
{
next: { tags: ['getAssigneeList'] },
},
)

return data.clients
}

const res = await fetch(`${apiUrl}/api/users?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, {
next: { tags: ['getAssigneeList'] },
})
return (await res.json()).users as IAssignee
const data = await fetchWithErrorHandler<{ users: IAssignee }>(
`${apiUrl}/api/users?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`,
{
next: { tags: ['getAssigneeList'] },
},
)
return data.users
}
export const AssigneeFetcher = async ({
token,
Expand Down
4 changes: 2 additions & 2 deletions src/app/_fetchers/TemplatesFetcher.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
export const fetchCache = 'force-no-store'

import { apiUrl } from '@/config'
import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler'
import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate'
import { ITemplate, PropsWithToken } from '@/types/interfaces'
import { PropsWithChildren } from 'react'

const getAllTemplates = async (token: string): Promise<ITemplate[]> => {
const res = await fetch(`${apiUrl}/api/tasks/templates?token=${token}`, {
const { data } = await fetchWithErrorHandler<{ data: ITemplate[] }>(`${apiUrl}/api/tasks/templates?token=${token}`, {
next: { tags: ['getAllTemplates'] },
})
const { data } = await res.json()
return data
}

Expand Down
12 changes: 7 additions & 5 deletions src/app/_fetchers/WorkflowStateFetcher.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const fetchCache = 'force-no-store'

import { apiUrl } from '@/config'
import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler'
import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate'
import { TaskResponse } from '@/types/dto/tasks.dto'
import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto'
Expand All @@ -12,11 +13,12 @@ interface WorkflowStateFetcherProps extends PropsWithToken, PropsWithChildren {
}

const getAllWorkflowStates = async (token: string): Promise<WorkflowStateResponse[]> => {
const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, {
next: { tags: ['getAllWorkflowStates'] },
})

const data = await res.json()
const data = await fetchWithErrorHandler<{ workflowStates: WorkflowStateResponse[] }>(
`${apiUrl}/api/workflow-states?token=${token}`,
{
next: { tags: ['getAllWorkflowStates'] },
},
)

return data.workflowStates
}
Expand Down
49 changes: 49 additions & 0 deletions src/app/_fetchers/fetchWithErrorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler'

describe('fetchWithErrorHandler', () => {
const originalFetch = global.fetch

afterEach(() => {
global.fetch = originalFetch
jest.clearAllMocks()
})

it('returns parsed JSON responses', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)

await expect(fetchWithErrorHandler<{ ok: boolean }>('https://example.test/api?token=secret')).resolves.toEqual({
ok: true,
})
})

it('reports non-JSON success responses with a redacted URL', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response('An error occurred while rendering', {
status: 200,
headers: { 'content-type': 'text/plain' },
}),
)

await expect(fetchWithErrorHandler('https://example.test/api?token=secret', undefined, 0)).rejects.toThrow(
'Expected JSON response from https://example.test/api?token=[redacted] but received text/plain (200): An error occurred while rendering',
)
})

it('reports non-OK responses with a redacted URL and body preview', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response('An error occurred during retry for token=secret', {
status: 500,
headers: { 'content-type': 'text/plain' },
}),
)

await expect(fetchWithErrorHandler('https://example.test/api?token=secret', undefined, 0)).rejects.toThrow(
'Fetch failed (500) for https://example.test/api?token=[redacted]: An error occurred during retry for token=[redacted]',
)
})
})
71 changes: 65 additions & 6 deletions src/app/_fetchers/fetchWithErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,83 @@ type FetchOptions = RequestInit & {
next?: { tags?: string[] }
}

export async function fetchWithErrorHandler<T>(input: RequestInfo, options?: FetchOptions, retries = 3): Promise<T> {
const ERROR_BODY_PREVIEW_LENGTH = 300

const getRequestUrl = (input: RequestInfo | URL): string => {
if (typeof input === 'string') return input
if (typeof URL !== 'undefined' && input instanceof URL) return input.toString()
if (typeof Request !== 'undefined' && input instanceof Request) return input.url
return String(input)
}

const redactToken = (value: string): string => value.replace(/(\btoken=)[^&\s]+/g, '$1[redacted]')

const getRedactedRequestUrl = (input: RequestInfo | URL): string => {
const url = getRequestUrl(input)

try {
const parsedUrl = new URL(url, 'http://localhost')
if (parsedUrl.searchParams.has('token')) {
parsedUrl.searchParams.set('token', '[redacted]')
}

if (url.startsWith('/')) return `${parsedUrl.pathname}${parsedUrl.search}`
return parsedUrl.toString().replaceAll('%5Bredacted%5D', '[redacted]')
} catch {
return redactToken(url)
}
}

const getBodyPreview = (body: string): string => {
const preview = redactToken(body).replace(/\s+/g, ' ').trim()
return preview.length > ERROR_BODY_PREVIEW_LENGTH ? `${preview.slice(0, ERROR_BODY_PREVIEW_LENGTH)}...` : preview
}

const getErrorBodyMessage = (body: string): string => {
if (!body) return 'Empty response body'

try {
const parsed = JSON.parse(body) as { error?: unknown; message?: unknown }
if (typeof parsed.error === 'string') return parsed.error
if (typeof parsed.message === 'string') return parsed.message
return getBodyPreview(JSON.stringify(parsed))
} catch {
return getBodyPreview(body)
}
}

const parseJsonResponse = <T>(body: string, res: Response, input: RequestInfo | URL): T => {
if (!body) return undefined as T

try {
return JSON.parse(body) as T
} catch {
const contentType = res.headers.get('content-type') || 'unknown content type'
throw new Error(
`Expected JSON response from ${getRedactedRequestUrl(input)} but received ${contentType} (${res.status}): ${getBodyPreview(
body,
)}`,
)
}
}

export async function fetchWithErrorHandler<T>(input: RequestInfo | URL, options?: FetchOptions, retries = 3): Promise<T> {
let lastError: any

for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(input, options)
const body = await res.text()

if (res.status === 500) {
throw new Error('Internal server error.')
throw new Error(`Fetch failed (${res.status}) for ${getRedactedRequestUrl(input)}: ${getErrorBodyMessage(body)}`)
}

if (!res.ok) {
const text = await res.text()
throw new Error(`Fetch failed (${res.status}): ${text}`)
throw new Error(`Fetch failed (${res.status}) for ${getRedactedRequestUrl(input)}: ${getErrorBodyMessage(body)}`)
}

const data = await res.json()
return data
return parseJsonResponse<T>(body, res, input)
} catch (error) {
lastError = error
if (attempt < retries) {
Expand Down
15 changes: 8 additions & 7 deletions src/app/client/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getViewSettings } from '@/app/(home)/page'
import { AssigneeCacheGetter } from '@/app/_cache/AssigneeCacheGetter'
import { AllTasksFetcher } from '@/app/_fetchers/AllTasksFetcher'
import { AssigneeFetcher } from '@/app/_fetchers/AssigneeFetcher'
import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler'
import { TemplatesFetcher } from '@/app/_fetchers/TemplatesFetcher'
import { ValidateNotificationCountFetcher } from '@/app/_fetchers/ValidateNotificationCountFetcher'
import { ModalNewTaskForm } from '@/app/ui/Modal_NewTaskForm'
Expand All @@ -29,18 +30,18 @@ import { z } from 'zod'
export const maxDuration = 300 //just to be safe. the validate count job might take longer that 15 seconds(default max duration of server components) which will make the app crash. Increasing the duration to 60.

async function getAllWorkflowStates(token: string): Promise<WorkflowStateResponse[]> {
const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, {
next: { tags: ['getAllWorkflowStates'] },
})

const data = await res.json()
const data = await fetchWithErrorHandler<{ workflowStates: WorkflowStateResponse[] }>(
`${apiUrl}/api/workflow-states?token=${token}`,
{
next: { tags: ['getAllWorkflowStates'] },
},
)

return data.workflowStates
}

async function getAllTasks(token: string): Promise<TaskResponse[]> {
const res = await fetch(`${apiUrl}/api/tasks?token=${token}`)
const data = await res.json()
const data = await fetchWithErrorHandler<{ tasks: TaskResponse[] }>(`${apiUrl}/api/tasks?token=${token}`)
return data.tasks
}

Expand Down
32 changes: 17 additions & 15 deletions src/app/configure-tasks-app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const fetchCache = 'force-no-store'
import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin'
import { TemplateBoard } from './ui/TemplateBoard'
import { apiUrl } from '@/config'
import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler'
import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto'
import { IAssignee, ITemplate } from '@/types/interfaces'
import { addTypeToAssignee } from '@/utils/addTypeToAssignee'
Expand All @@ -19,32 +20,32 @@ import { StatusCustomizationSection } from '@/app/configure-tasks-app/ui/StatusC
import { Stack } from '@mui/material'

async function getAllWorkflowStates(token: string): Promise<WorkflowStateResponse[]> {
const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, {
next: { tags: ['getAllWorkflowStates'] },
})

const data = await res.json()
const data = await fetchWithErrorHandler<{ workflowStates: WorkflowStateResponse[] }>(
`${apiUrl}/api/workflow-states?token=${token}`,
{
next: { tags: ['getAllWorkflowStates'] },
},
)

return data.workflowStates
}

async function getAssigneeList(token: string): Promise<IAssignee> {
const res = await fetch(`${apiUrl}/api/users?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`, {
next: { tags: ['getAssigneeList'] },
})

const data = await res.json()
const data = await fetchWithErrorHandler<{ users: IAssignee }>(
`${apiUrl}/api/users?token=${token}&limit=${MAX_FETCH_ASSIGNEE_COUNT}`,
{
next: { tags: ['getAssigneeList'] },
},
)

return data.users
}

async function getAllTemplates(token: string): Promise<ITemplate[]> {
const res = await fetch(`${apiUrl}/api/tasks/templates?token=${token}`, {
const templates = await fetchWithErrorHandler<{ data: ITemplate[] }>(`${apiUrl}/api/tasks/templates?token=${token}`, {
next: { tags: ['getAllTemplates'] },
})

const templates = await res.json()

return templates.data
}

Expand All @@ -60,8 +61,9 @@ async function getWorkspace(token: string): Promise<WorkspaceResponse> {
}

async function getWorkspaceSetting(token: string): Promise<{ autoArchiveAfterDays: number }> {
const res = await fetch(`${apiUrl}/api/workspace-settings?token=${token}`, { cache: 'no-store' })
return await res.json()
return await fetchWithErrorHandler<{ autoArchiveAfterDays: number }>(`${apiUrl}/api/workspace-settings?token=${token}`, {
cache: 'no-store',
})
}

interface ConfigureTasksAppPageProps {
Expand Down
Loading
Loading