feat(cache): add stale get caching for report filters and summaries
This commit is contained in:
147
src/api/cache.ts
Normal file
147
src/api/cache.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { buildApiError, authFetch } from "./client"
|
||||
import { getAccessToken, SESSION_CHANGED_EVENT } from "../lib/session"
|
||||
|
||||
const CACHE_PREFIX = "api-get-cache:v2:"
|
||||
|
||||
interface CacheEntry<T> {
|
||||
expiresAt: number
|
||||
data: T
|
||||
namespaces: string[]
|
||||
}
|
||||
|
||||
const memoryCache = new Map<string, CacheEntry<unknown>>()
|
||||
|
||||
const cloneData = <T>(value: T): T => {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value)
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
const decodeBase64Url = (value: string) => {
|
||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=")
|
||||
return atob(padded)
|
||||
}
|
||||
|
||||
const getCurrentUserCacheKey = () => {
|
||||
const token = getAccessToken()
|
||||
if (!token) return "anon"
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64Url(token.split(".")[1] || ""))
|
||||
return String(payload.user_id || payload.sub || "anon")
|
||||
} catch {
|
||||
return "anon"
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeEndpoint = (endpoint: string) => {
|
||||
const base = endpoint.startsWith("http") ? undefined : "https://cache.local"
|
||||
const url = new URL(endpoint, base)
|
||||
const entries = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
|
||||
if (aKey === bKey) return aValue.localeCompare(bValue)
|
||||
return aKey.localeCompare(bKey)
|
||||
})
|
||||
const normalizedSearch = new URLSearchParams()
|
||||
entries.forEach(([key, value]) => normalizedSearch.append(key, value))
|
||||
const queryString = normalizedSearch.toString()
|
||||
return `${url.pathname}${queryString ? `?${queryString}` : ""}`
|
||||
}
|
||||
|
||||
const buildStorageKey = (endpoint: string) => `${CACHE_PREFIX}${getCurrentUserCacheKey()}:${normalizeEndpoint(endpoint)}`
|
||||
|
||||
const readStoredEntry = <T>(key: string): CacheEntry<T> | null => {
|
||||
const cached = memoryCache.get(key)
|
||||
if (cached) {
|
||||
if (cached.expiresAt > Date.now()) {
|
||||
return cached as CacheEntry<T>
|
||||
}
|
||||
memoryCache.delete(key)
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CacheEntry<T>
|
||||
if (parsed.expiresAt <= Date.now()) {
|
||||
sessionStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
memoryCache.set(key, parsed as CacheEntry<unknown>)
|
||||
return parsed
|
||||
} catch {
|
||||
sessionStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writeEntry = <T>(key: string, entry: CacheEntry<T>) => {
|
||||
memoryCache.set(key, entry as CacheEntry<unknown>)
|
||||
sessionStorage.setItem(key, JSON.stringify(entry))
|
||||
}
|
||||
|
||||
export const invalidateApiCache = (namespaces: string[]) => {
|
||||
if (!namespaces.length) return
|
||||
|
||||
const namespaceSet = new Set(namespaces)
|
||||
const removeIfMatches = (key: string, entry: CacheEntry<unknown>) => {
|
||||
if (entry.namespaces.some((namespace) => namespaceSet.has(namespace))) {
|
||||
memoryCache.delete(key)
|
||||
sessionStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
memoryCache.forEach((entry, key) => removeIfMatches(key, entry))
|
||||
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||
const key = sessionStorage.key(index)
|
||||
if (!key || !key.startsWith(CACHE_PREFIX)) continue
|
||||
try {
|
||||
const parsed = JSON.parse(sessionStorage.getItem(key) || "null") as CacheEntry<unknown> | null
|
||||
if (parsed) {
|
||||
removeIfMatches(key, parsed)
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const clearApiCache = () => {
|
||||
memoryCache.clear()
|
||||
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||
const key = sessionStorage.key(index)
|
||||
if (key?.startsWith(CACHE_PREFIX)) {
|
||||
sessionStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cachedGetJson = async <T>(
|
||||
endpoint: string,
|
||||
options: { ttlMs: number; namespaces: string[]; bypass?: boolean },
|
||||
): Promise<T> => {
|
||||
const storageKey = buildStorageKey(endpoint)
|
||||
if (!options.bypass) {
|
||||
const cached = readStoredEntry<T>(storageKey)
|
||||
if (cached) {
|
||||
return cloneData(cached.data)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await authFetch(endpoint)
|
||||
if (!response.ok) {
|
||||
throw await buildApiError(response)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as T
|
||||
writeEntry(storageKey, {
|
||||
expiresAt: Date.now() + options.ttlMs,
|
||||
data,
|
||||
namespaces: options.namespaces,
|
||||
})
|
||||
return cloneData(data)
|
||||
}
|
||||
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, clearApiCache)
|
||||
@@ -1,12 +1,26 @@
|
||||
import { authFetch } from "./client";
|
||||
|
||||
export const getClients = async (
|
||||
workspaceId: string,
|
||||
search: string = "",
|
||||
ordering: string = "",
|
||||
limit: number = 10,
|
||||
offset: number = 0
|
||||
) => {
|
||||
import { authFetch } from "./client";
|
||||
import { cachedGetJson, invalidateApiCache } from "./cache";
|
||||
|
||||
export interface Client {
|
||||
id: string
|
||||
name: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: T[]
|
||||
}
|
||||
|
||||
export const getClients = async (
|
||||
workspaceId: string,
|
||||
search: string = "",
|
||||
ordering: string = "",
|
||||
limit: number = 10,
|
||||
offset: number = 0
|
||||
): Promise<PaginatedResponse<Client>> => {
|
||||
const queryParams = new URLSearchParams({
|
||||
workspace: workspaceId,
|
||||
limit: limit.toString(),
|
||||
@@ -16,12 +30,11 @@ export const getClients = async (
|
||||
if (search) queryParams.append("search", search);
|
||||
if (ordering) queryParams.append("ordering", ordering);
|
||||
|
||||
const response = await authFetch(`/api/clients/?${queryParams.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch clients");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
return cachedGetJson<PaginatedResponse<Client>>(`/api/clients/?${queryParams.toString()}`, {
|
||||
ttlMs: 5 * 60 * 1000,
|
||||
namespaces: ["clients"],
|
||||
});
|
||||
};
|
||||
|
||||
export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => {
|
||||
const response = await authFetch("/api/clients/", {
|
||||
@@ -32,12 +45,14 @@ export const createClient = async (workspaceId: string, data: { name: string; no
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to create client");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to create client");
|
||||
}
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["clients", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const updateClient = async (id: string, data: { name?: string; notes?: string }) => {
|
||||
const response = await authFetch(`/api/clients/${id}/`, {
|
||||
@@ -45,25 +60,28 @@ export const updateClient = async (id: string, data: { name?: string; notes?: st
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to update client");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to update client");
|
||||
}
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["clients", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const deleteClient = async (id: string) => {
|
||||
const response = await authFetch(`/api/clients/${id}/`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to delete client");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return { success: true };
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to delete client");
|
||||
}
|
||||
invalidateApiCache(["clients", "reports"]);
|
||||
|
||||
if (response.status === 204) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return response.json().catch(() => ({ success: true }));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authFetch } from "./client";
|
||||
import { cachedGetJson, invalidateApiCache } from "./cache";
|
||||
|
||||
interface AuditUser {
|
||||
id: string;
|
||||
@@ -56,10 +57,10 @@ export const getProjects = async (
|
||||
if (params.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString());
|
||||
if (params.ordering !== undefined) queryParams.append("ordering", params.ordering.toString());
|
||||
|
||||
const response = await authFetch(`/api/projects/?${queryParams.toString()}`);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to fetch projects");
|
||||
const data = await response.json();
|
||||
const data = await cachedGetJson<any>(`/api/projects/?${queryParams.toString()}`, {
|
||||
ttlMs: 5 * 60 * 1000,
|
||||
namespaces: ["projects"],
|
||||
});
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data?.items)) {
|
||||
return {
|
||||
@@ -89,12 +90,14 @@ export const createProject = async (
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to create project");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to create project");
|
||||
}
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["projects", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const updateProject = async (
|
||||
id: string,
|
||||
@@ -105,25 +108,28 @@ export const updateProject = async (
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to update project");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to update project");
|
||||
}
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["projects", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const deleteProject = async (id: string) => {
|
||||
const response = await authFetch(`/api/projects/${id}/`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to delete project");
|
||||
}
|
||||
|
||||
if (response.status === 204) return { success: true };
|
||||
return response.json().catch(() => ({ success: true }));
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to delete project");
|
||||
}
|
||||
invalidateApiCache(["projects", "reports"]);
|
||||
|
||||
if (response.status === 204) return { success: true };
|
||||
return response.json().catch(() => ({ success: true }));
|
||||
};
|
||||
|
||||
export const toggleArchiveProject = async (id: string) => {
|
||||
@@ -131,9 +137,11 @@ export const toggleArchiveProject = async (id: string) => {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`);
|
||||
}
|
||||
return response.json();
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["projects", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authFetch } from "./client";
|
||||
import { cachedGetJson, invalidateApiCache } from "./cache";
|
||||
|
||||
export interface RateUser {
|
||||
id: string;
|
||||
@@ -55,13 +56,35 @@ const ensurePaginated = async <T>(response: Response): Promise<PaginatedResponse
|
||||
};
|
||||
|
||||
export const getPriceUnits = async () => {
|
||||
const response = await authFetch("/api/price-units/");
|
||||
return ensurePaginated<PriceUnit>(response);
|
||||
const data = await cachedGetJson<any>("/api/price-units/", {
|
||||
ttlMs: 5 * 60 * 1000,
|
||||
namespaces: ["price-units"],
|
||||
});
|
||||
if (Array.isArray(data)) {
|
||||
return { count: data.length, next: null, previous: null, results: data };
|
||||
}
|
||||
return {
|
||||
count: data.count || data.results?.length || 0,
|
||||
next: data.next || null,
|
||||
previous: data.previous || null,
|
||||
results: data.results || [],
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkspaceUserRates = async (workspaceId: string) => {
|
||||
const response = await authFetch(`/api/workspace-user-rates/?workspace=${workspaceId}`);
|
||||
return ensurePaginated<WorkspaceUserRate>(response);
|
||||
const data = await cachedGetJson<any>(`/api/workspace-user-rates/?workspace=${workspaceId}`, {
|
||||
ttlMs: 5 * 60 * 1000,
|
||||
namespaces: ["workspace-rates"],
|
||||
});
|
||||
if (Array.isArray(data)) {
|
||||
return { count: data.length, next: null, previous: null, results: data };
|
||||
}
|
||||
return {
|
||||
count: data.count || data.results?.length || 0,
|
||||
next: data.next || null,
|
||||
previous: data.previous || null,
|
||||
results: data.results || [],
|
||||
};
|
||||
};
|
||||
|
||||
export const createWorkspaceUserRate = async (data: {
|
||||
@@ -78,6 +101,7 @@ export const createWorkspaceUserRate = async (data: {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to save workspace user rate");
|
||||
}
|
||||
invalidateApiCache(["workspace-rates", "reports"]);
|
||||
return response.json() as Promise<WorkspaceUserRate>;
|
||||
};
|
||||
|
||||
@@ -93,6 +117,7 @@ export const updateWorkspaceUserRate = async (
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to update workspace user rate");
|
||||
}
|
||||
invalidateApiCache(["workspace-rates", "reports"]);
|
||||
return response.json() as Promise<WorkspaceUserRate>;
|
||||
};
|
||||
|
||||
@@ -104,4 +129,5 @@ export const deleteWorkspaceUserRate = async (rateId: string) => {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to delete workspace user rate");
|
||||
}
|
||||
invalidateApiCache(["workspace-rates", "reports"]);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authFetch } from "./client";
|
||||
import { cachedGetJson } from "./cache";
|
||||
|
||||
export type ReportPeriod =
|
||||
| "this_week"
|
||||
@@ -150,15 +151,17 @@ const toQueryString = (filters: ReportFilters) => {
|
||||
};
|
||||
|
||||
export const getChartReport = async (filters: ReportFilters): Promise<ChartReportResponse> => {
|
||||
const response = await authFetch(`/api/reports/chart/?${toQueryString(filters)}`);
|
||||
if (!response.ok) throw new Error("Failed to load chart report");
|
||||
return response.json();
|
||||
return cachedGetJson(`/api/reports/chart/?${toQueryString(filters)}`, {
|
||||
ttlMs: 60 * 1000,
|
||||
namespaces: ["reports"],
|
||||
});
|
||||
};
|
||||
|
||||
export const getTableReport = async (filters: ReportFilters): Promise<TableReportResponse> => {
|
||||
const response = await authFetch(`/api/reports/table/?${toQueryString(filters)}`);
|
||||
if (!response.ok) throw new Error("Failed to load table report");
|
||||
return response.json();
|
||||
return cachedGetJson(`/api/reports/table/?${toQueryString(filters)}`, {
|
||||
ttlMs: 60 * 1000,
|
||||
namespaces: ["reports"],
|
||||
});
|
||||
};
|
||||
|
||||
export const getDayDetailsReport = async (
|
||||
@@ -166,9 +169,10 @@ export const getDayDetailsReport = async (
|
||||
day: string,
|
||||
): Promise<DayDetailsResponse> => {
|
||||
const query = `${toQueryString(filters)}&day=${encodeURIComponent(day)}`;
|
||||
const response = await authFetch(`/api/reports/day-details/?${query}`);
|
||||
if (!response.ok) throw new Error("Failed to load day details");
|
||||
return response.json();
|
||||
return cachedGetJson(`/api/reports/day-details/?${query}`, {
|
||||
ttlMs: 30 * 1000,
|
||||
namespaces: ["reports"],
|
||||
});
|
||||
};
|
||||
|
||||
export const createReportExport = async (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authFetch } from "./client";
|
||||
import { cachedGetJson, invalidateApiCache } from "./cache";
|
||||
|
||||
interface AuditUser {
|
||||
id: string;
|
||||
@@ -36,9 +37,10 @@ export const getTags = async (
|
||||
if (params.search) query.append("search", params.search);
|
||||
if (params.ordering) query.append("ordering", params.ordering);
|
||||
|
||||
const response = await authFetch(`/api/tags/?${query.toString()}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch tags");
|
||||
return response.json();
|
||||
return cachedGetJson(`/api/tags/?${query.toString()}`, {
|
||||
ttlMs: 5 * 60 * 1000,
|
||||
namespaces: ["tags"],
|
||||
});
|
||||
};
|
||||
|
||||
export const createTag = async (workspaceId: string, data: { name: string; color: string }) => {
|
||||
@@ -50,7 +52,9 @@ export const createTag = async (workspaceId: string, data: { name: string; color
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to create tag");
|
||||
return response.json();
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["tags", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const updateTag = async (id: string, data: Partial<Pick<Tag, "name" | "color">>) => {
|
||||
@@ -59,7 +63,9 @@ export const updateTag = async (id: string, data: Partial<Pick<Tag, "name" | "co
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to update tag");
|
||||
return response.json();
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["tags", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const deleteTag = async (id: string) => {
|
||||
@@ -67,4 +73,5 @@ export const deleteTag = async (id: string) => {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to delete tag");
|
||||
invalidateApiCache(["tags", "reports"]);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authFetch } from "./client";
|
||||
import { invalidateApiCache } from "./cache";
|
||||
|
||||
export interface TimeEntryProjectDetails {
|
||||
id: string;
|
||||
@@ -109,7 +110,9 @@ export const createTimeEntry = async (payload: TimeEntryPayload) => {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to create time entry");
|
||||
return response.json();
|
||||
const data = await response.json();
|
||||
invalidateApiCache(["reports"]);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) => {
|
||||
@@ -118,7 +121,9 @@ export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) =>
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to update time entry");
|
||||
return response.json();
|
||||
const data = await response.json();
|
||||
invalidateApiCache(["reports"]);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const stopTimeEntry = async (id: string, endTime?: string) => {
|
||||
@@ -127,7 +132,9 @@ export const stopTimeEntry = async (id: string, endTime?: string) => {
|
||||
body: JSON.stringify(endTime ? { end_time: endTime } : {}),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to stop time entry");
|
||||
return response.json();
|
||||
const data = await response.json();
|
||||
invalidateApiCache(["reports"]);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteTimeEntry = async (id: string) => {
|
||||
@@ -135,4 +142,5 @@ export const deleteTimeEntry = async (id: string) => {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to delete time entry");
|
||||
invalidateApiCache(["reports"]);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authFetch } from "./client";
|
||||
import { authFetch } from "./client";
|
||||
import { cachedGetJson, invalidateApiCache } from "./cache";
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
@@ -52,13 +53,10 @@ const toQueryString = (params?: Record<string, QueryValue>) => {
|
||||
export const fetchWorkspaces = async (params?: Record<string, QueryValue>): Promise<PaginatedResponse<Workspace>> => {
|
||||
const query = toQueryString(params);
|
||||
const url = `/api/workspaces/${query ? `?${query}` : ''}`;
|
||||
const response = await authFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch workspaces");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await cachedGetJson<any>(url, {
|
||||
ttlMs: 60 * 1000,
|
||||
namespaces: ["workspaces"],
|
||||
});
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return { count: data.length, next: null, previous: null, results: data };
|
||||
@@ -109,12 +107,14 @@ export const createWorkspace = async (data: {
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to create workspace');
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to create workspace');
|
||||
}
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["workspaces", "workspace-memberships", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const updateWorkspace = async (
|
||||
id: string,
|
||||
@@ -144,30 +144,32 @@ export const updateWorkspace = async (
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update workspace');
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update workspace');
|
||||
}
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["workspaces"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const deleteWorkspace = async (id: string): Promise<void> => {
|
||||
const response = await authFetch(`/api/workspaces/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete workspace');
|
||||
}
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete workspace');
|
||||
}
|
||||
invalidateApiCache(["workspaces", "workspace-memberships", "workspace-rates", "reports"]);
|
||||
};
|
||||
|
||||
export const fetchWorkspaceMemberships = async (params?: Record<string, QueryValue>): Promise<PaginatedResponse<WorkspaceMembership>> => {
|
||||
const queryParams = toQueryString(params);
|
||||
const response = await authFetch(`/api/workspace-memberships/?${queryParams.toString()}`);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to fetch workspace memberships");
|
||||
|
||||
const data = await response.json();
|
||||
const data = await cachedGetJson<any>(`/api/workspace-memberships/?${queryParams.toString()}`, {
|
||||
ttlMs: 5 * 60 * 1000,
|
||||
namespaces: ["workspace-memberships"],
|
||||
});
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return { count: data.length, next: null, previous: null, results: data };
|
||||
@@ -187,23 +189,26 @@ export const addWorkspaceMembership = async (data: { workspace: string; user: st
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to add workspace membership');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to add workspace membership');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["workspace-memberships", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const removeWorkspaceMembership = async (membershipId: string): Promise<void> => {
|
||||
const response = await authFetch(`/api/workspace-memberships/${membershipId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to remove workspace membership');
|
||||
}
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to remove workspace membership');
|
||||
}
|
||||
invalidateApiCache(["workspace-memberships", "reports"]);
|
||||
};
|
||||
|
||||
export const updateWorkspaceMembership = async (membershipId: string | number, data: { role: string }) => {
|
||||
const response = await authFetch(`/api/workspace-memberships/${membershipId}/`, {
|
||||
@@ -211,10 +216,12 @@ export const updateWorkspaceMembership = async (membershipId: string | number, d
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update membership');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update membership');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
invalidateApiCache(["workspace-memberships", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user