import { createContext, useCallback, useContext, useRef, useState } from "react"; import { nanoid } from "nanoid"; import api from "@/utils/api.util"; import { useAuth } from "@/contexts/AuthContext"; import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util"; import { toast } from "sonner"; // ─── Generic authenticated SSE reader ────────────────────────────────────── // // Native EventSource can't set the Authorization header this app authenticates // with, so SSE endpoints are consumed via a manually-parsed, authenticated // fetch() stream instead of EventSource. Returns a stop() function. Failures // here are swallowed on purpose — this is a best-effort progress signal on // top of a request that already carries its own real result, never // load-bearing on its own. function streamSSE(url, token, onEvent) { const controller = new AbortController(); (async () => { try { const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : undefined, signal: controller.signal, }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const chunks = buffer.split("\n\n"); buffer = chunks.pop(); // keep the last, possibly-incomplete chunk for next read for (const chunk of chunks) { const line = chunk.split("\n").find((l) => l.startsWith("data: ")); if (!line) continue; const data = JSON.parse(line.slice(6)); onEvent(data); if (data.done) return; } } } catch (err) { if (err.name !== "AbortError") console.warn("[SSE STREAM]", url, err.message); } })(); return () => controller.abort(); } // Document-conversion stage progress (compiling/validating/generating) — see // convertAssetToMarkdown() below. Backend broadcaster is // services/uploadProgress.service.js, keyed by a client-generated job id. const streamConvertProgress = (jobId, token, onProgress) => streamSSE(`${import.meta.env.VITE_API_URL}/admin/assets/convert-progress/${jobId}`, token, onProgress); const AssetsContext = createContext(null); export function useAssets() { const ctx = useContext(AssetsContext); if (!ctx) throw new Error("useAssets must be used within an AssetsProvider"); return ctx; } // ─── Initial States ──────────────────────────────────────────────────────────── const PAGINATION_INIT = { page: 1, limit: 10, totalRecords: 0, totalPages: 0, hasPrevPage: false, hasNextPage: false, }; // No Redis yet — these caches are plain in-memory (per browser tab, cleared on // refresh) to absorb the repeated open/close traffic pickers like // AssetPickerSheet generate against Postgres and the media-token endpoint. const LIST_CACHE_TTL_MS = 20_000; // short: just enough to survive rapid open/close flapping const MEDIA_TOKEN_TTL_MS = 30 * 60 * 1000; // mirrors TOKEN_TTL_SEC in media.controller.js const MEDIA_TOKEN_REFRESH_MARGIN_MS = 2 * 60 * 1000; // re-mint a bit before real expiry const cacheKeyFor = (scope, { page, limit, filters, sort }) => `${scope}:${JSON.stringify({ page, limit, filters, sort })}`; export function AssetsProvider({ children }) { const { accessTokenRef } = useAuth(); const [assets, setAssets] = useState([]); const [attributes, setAttributes] = useState([]); const [pagination, setPagination] = useState(PAGINATION_INIT); const [selectedAsset, setSelectedAsset] = useState(null); const [loading, setLoading] = useState(false); // { [asset_id]: { token, thumbnail_url, issuedAt } } — shared across every // picker instance so tokens survive sheet open/close for their full TTL. const [mediaTokens, setMediaTokens] = useState({}); const mediaTokensRef = useRef({}); const listCacheRef = useRef(new Map()); const invalidateListCache = () => listCacheRef.current.clear(); // Seeds mediaTokens from stream_token/thumbnail_url fields the backend now // embeds directly in S3 rows of GET /admin/assets — so getMediaTokens (called // right after fetchAssets by pickers/tables) finds them already cached and // skips the batch round-trip instead of re-requesting tokens it just got. const seedMediaTokensFromRows = (rows = []) => { const issuedAt = Date.now(); const next = {}; for (const row of rows) { if (row.stream_token) { next[String(row.asset_id)] = { token: row.stream_token, thumbnail_url: row.thumbnail_url ?? null, issuedAt }; } } if (Object.keys(next).length) { mediaTokensRef.current = { ...mediaTokensRef.current, ...next }; setMediaTokens(mediaTokensRef.current); } }; const request = useCallback(async (fn) => { setLoading(true); try { return await fn(); } catch (err) { const message = err?.response?.data?.message || err.message || "Something went wrong."; toast(message); return null; } finally { setLoading(false); } }, []); // ─── GET /api/admin/assets ──────────────────────────────────────────────── // Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so toggling // a picker like AssetPickerSheet open/closed doesn't re-hit Postgres for the // same query within the TTL window. Pass force: true to bypass the cache. const fetchAssets = useCallback( ({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => { const key = cacheKeyFor("assets", { page, limit, filters, sort }); const cached = listCacheRef.current.get(key); if (!force && cached && Date.now() - cached.fetchedAt < LIST_CACHE_TTL_MS) { setAssets(cached.assets); setPagination(cached.pagination); setAttributes(cached.attributes); seedMediaTokensFromRows(cached.assets); return Promise.resolve(cached.raw); } return request(async () => { const { data } = await api.get("/admin/assets", { params: { page, limit, filters: filters.length ? JSON.stringify(filters) : undefined, sort: sort.length ? JSON.stringify(sort) : undefined, }, }); const result = data?.data; setAssets(result?.data ?? []); setPagination(result?.pagination ?? PAGINATION_INIT); setAttributes(result.attributes); seedMediaTokensFromRows(result?.data); listCacheRef.current.set(key, { assets: result?.data ?? [], pagination: result?.pagination ?? PAGINATION_INIT, attributes: result.attributes, raw: data.data, fetchedAt: Date.now(), }); return data.data; }); }, [request] ); // ─── POST /api/admin/media/tokens (batch) ──────────────────────────────── // Skips any asset_id whose cached token is still within its TTL (minus a // safety margin) instead of re-minting a fresh JWT/presigned URL every time // a picker reopens. Shared across all picker instances via context state. const getMediaTokens = useCallback((assetIds = []) => { const now = Date.now(); const missing = assetIds .map(String) .filter((id) => { const cached = mediaTokensRef.current[id]; return !cached || (now - cached.issuedAt) > (MEDIA_TOKEN_TTL_MS - MEDIA_TOKEN_REFRESH_MARGIN_MS); }); if (!missing.length) return Promise.resolve(mediaTokensRef.current); return api.post("/admin/media/tokens", { asset_ids: missing }).then(({ data }) => { const tokens = data.data?.tokens ?? {}; const thumbnails = data.data?.thumbnails ?? {}; const issuedAt = Date.now(); const next = {}; for (const [id, token] of Object.entries(tokens)) { next[id] = { token, thumbnail_url: thumbnails[id] ?? null, issuedAt }; } mediaTokensRef.current = { ...mediaTokensRef.current, ...next }; setMediaTokens(mediaTokensRef.current); return mediaTokensRef.current; }); }, []); // ─── GET /api/admin/assets/:assetId ─────────────────────────────────────── const fetchAsset = useCallback( (assetId) => request(async () => { const res = await api.get(`/admin/assets/${assetId}`); setSelectedAsset(res.data?.data?.data ?? null); return res.data; }), [request] ); // ─── GET /api/admin/assets/archived ─────────────────────────────────────── const fetchArchivedAssets = useCallback( ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => request(async () => { const { data } = await api.get("/admin/assets/archived", { params: { page, limit, filters: filters.length ? JSON.stringify(filters) : undefined, sort: sort.length ? JSON.stringify(sort) : undefined, }, }); const final_data = data?.data; setAssets(final_data?.data ?? []); setPagination(final_data?.pagination ?? PAGINATION_INIT); setAttributes(final_data.attributes); return data; }), [request] ); // ─── POST /api/admin/assets/presign + direct PUT + POST /api/admin/assets ── // // onProgress?: ({ phase: 'uploading'|'processing'|'done', pct }) => void // "uploading" — browser -> storage, real bytes sent directly (this // backend is never in that data path at all anymore). // "processing" — brief server-side step once the upload lands: reads the // object back (HeadObjectCommand), runs ffprobe for // video/audio, inserts the DB row. const uploadAsset = useCallback( ({ file, thumbnail, onProgress, ...rest }) => request(async () => { const [mainPresign, thumbPresign] = await Promise.all([ presignAssetUpload(file), thumbnail ? presignAssetUpload(thumbnail) : Promise.resolve(null), ]); const storage_key = mainPresign.key; await Promise.all([ uploadPresigned(file, mainPresign, (pct) => onProgress?.({ phase: "uploading", pct })), thumbPresign ? uploadPresigned(thumbnail, thumbPresign) : Promise.resolve(), ]); onProgress?.({ phase: "processing", pct: 100 }); const res = await api.post("/admin/assets", { storage_key, thumbnail_storage_key: thumbPresign?.key, original_name: file.name, // Fallback only — the backend prefers storage's own // Content-Type, this just covers the rare case a browser // sent the PUT with no Content-Type at all (empty File.type). mimetype: file.type || undefined, ...rest, }); const asset = res.data?.data?.data ?? null; if (asset) { setAssets((prev) => [asset, ...prev]); invalidateListCache(); toast("Asset uploaded successfully."); } onProgress?.({ phase: "done", pct: 100 }); return res.data; }), [request] ); // ─── POST /api/admin/assets/:assetId/convert-to-markdown ───────────────── // // PDF/PPTX -> Markdown, text only (see documentConversion.service.js on // the backend for why OCR/images are out of scope). Nothing is persisted // by this call — the result is a draft the caller (Document Import block) // only keeps if the admin explicitly inserts it. On failure this resolves // to null (the shared `request()` wrapper already toasts the backend's // specific error message, e.g. "No readable text found..."). // // onProgress?: ({ phase: 'compiling'|'validating'|'generating'|'done'|'error' }) => void const convertAssetToMarkdown = useCallback( (assetId, { onProgress } = {}) => request(async () => { const jobId = nanoid(); const stopStream = onProgress ? streamConvertProgress(jobId, accessTokenRef.current, onProgress) : null; try { const res = await api.post(`/admin/assets/${assetId}/convert-to-markdown`, { jobId }); return res.data?.data ?? null; } finally { stopStream?.(); } }), [request, accessTokenRef] ); // ─── PATCH /api/admin/assets/:assetId ──────────────────────────────────── // // A replacement file (video thumbnail, or an image/audio asset's main // file) goes out the same presigned direct-to-storage path as // uploadAsset() above — this backend never buffers it. The PATCH body // itself is plain JSON either way (storage_key when a file was replaced). const updateAsset = useCallback( (assetId, fields, file = null) => request(async () => { let filePayload = {}; if (file) { const presigned = await presignAssetUpload(file); await uploadPresigned(file, presigned); filePayload = { storage_key: presigned.key, original_name: file.name, mimetype: file.type || undefined, }; } const res = await api.patch(`/admin/assets/${assetId}`, { ...fields, ...filePayload }); const asset = res.data?.data?.data ?? null; if (asset) { setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a))); setSelectedAsset(asset); invalidateListCache(); toast("Asset updated successfully."); } return res.data; }), [request] ); // ─── DELETE /api/admin/assets/:assetId ─────────────────────────────────── const archiveAsset = useCallback( (assetId, { deletedBy } = {}) => request(async () => { const res = await api.delete(`/admin/assets/${assetId}`, { data: { deletedBy }, }); setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev)); invalidateListCache(); toast("Asset archived."); return res.data; }), [request] ); // ─── DELETE /api/admin/assets/bulk ─────────────────────────────────────── const archiveAssets = useCallback( ({ ids }, { deletedBy } = {}) => request(async () => { const res = await api.delete("/admin/assets/bulk", { data: { ids, deletedBy }, }); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); invalidateListCache(); toast(`${ids.length} asset(s) archived.`); return res.data; }), [request] ); // ─── PATCH /api/admin/assets/:assetId/restore ──────────────────────────── const restoreAsset = useCallback( (assetId) => request(async () => { const res = await api.patch(`/admin/assets/${assetId}/restore`); const asset = res.data?.data?.data ?? null; if (asset) { setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); invalidateListCache(); toast("Asset restored."); } return res.data; }), [request] ); // ─── PATCH /api/admin/assets/bulk-restore ──────────────────────────────── const restoreAssets = useCallback( ({ ids }) => request(async () => { const res = await api.patch("/admin/assets/bulk-restore", { ids }); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); invalidateListCache(); toast(`${ids.length} asset(s) restored.`); return res.data; }), [request] ); // ─── DELETE /api/admin/assets/:assetId/permanent ───────────────────────── const permanentlyDeleteAsset = useCallback( (assetId) => request(async () => { const res = await api.delete(`/admin/assets/${assetId}/permanent`); setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev)); invalidateListCache(); toast("Asset permanently deleted."); return res.data; }), [request] ); // ─── DELETE /api/admin/assets/bulk/permanent ───────────────────────────── const permanentlyDeleteAssets = useCallback( ({ ids }) => request(async () => { const res = await api.delete("/admin/assets/bulk/permanent", { data: { ids }, }); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); invalidateListCache(); toast(`${ids.length} asset(s) permanently deleted.`); return res.data; }), [request] ); // ─── GET /api/admin/assets/field-values ────────────────────────────────────── const fetchAssetFieldValues = useCallback( (field) => request(async () => { const res = await api.get("/admin/assets/field-values", { params: { field } }); return res.data?.data; }), [request] ); return ( {children} ); }