diff --git a/MD/PERFORMANCE_AUDIT.md b/MD/PERFORMANCE_AUDIT.md index 2ae549a1..ea7ee324 100644 --- a/MD/PERFORMANCE_AUDIT.md +++ b/MD/PERFORMANCE_AUDIT.md @@ -27,11 +27,11 @@ ### P2: Medium Impact, Medium Effort -- [ ] #12 — Add pagination to all list endpoints -- [ ] #13 — Remove dead ownerId parameter from frontend fetch -- [ ] #14 — Deduplicate project fetching (useSyncData vs useProjects) -- [ ] #15 — Reuse Puppeteer browser instance OR switch to API -- [ ] #16 — Cache architecture analysis results +- [x] #12 — Add pagination to all list endpoints +- [x] #13 — Remove dead ownerId parameter from frontend fetch +- [x] #14 — Deduplicate project fetching (useSyncData vs useProjects) +- [x] #15 — Reuse Puppeteer browser instance OR switch to API +- [x] #16 — Cache architecture analysis results ### P3: Low-Medium Impact, Low Effort @@ -39,8 +39,8 @@ - [ ] #18 — Add text index for user search - [ ] #19 — Remove console.log statements, use structured logger - [ ] #20 — Remove unused Redux Toolkit dependency -- [ ] #21 — Use ETags for GitHub API calls -- [ ] #22 — Support pagination for GitHub repo listing +- [x] #21 — Use ETags for GitHub API calls +- [x] ~~#22 — Support pagination for GitHub repo listing~~ --- @@ -759,7 +759,7 @@ This is a security issue. If the environment variable is missing, webhook authen | 19 | Remove console.log statements, use structured logger | Production logs bloat | **Code quality** | | 20 | Remove unused Redux Toolkit dependency | Dead code | **~20KB bundle ↓** | | 21 | Use ETags for GitHub API calls | Rate limit conservation | **Rate limit efficiency** | -| 22 | Support pagination for GitHub repo listing | Users with 100+ repos | **UX improvement** | +| 22 | ~~Support pagination for GitHub repo listing~~ | Users with 100+ repos | **UX improvement** | --- diff --git a/PERFORMANCE_AUDIT.md b/PERFORMANCE_AUDIT.md new file mode 100644 index 00000000..73c0d83e --- /dev/null +++ b/PERFORMANCE_AUDIT.md @@ -0,0 +1,22 @@ +# Performance Audit Todo List + +## Frontend (React/Vite) +- [ ] Analyze Vite bundle size (e.g., using `rollup-plugin-visualizer`). +- [ ] Implement lazy loading (`React.lazy`) for heavy routes and components. +- [ ] Audit React component re-renders and apply `useMemo`/`useCallback` where appropriate. +- [ ] Optimize static assets (images, fonts, large icons) for faster loading. +- [ ] Run Lighthouse audits on the frontend production build. +- [ ] Review and clean up unnecessary dependencies in `package.json`. + +## Backend (Node/Express) +- [ ] Review MongoDB collection structures and add necessary indexes for slow queries. +- [ ] Optimize database queries (avoid resolving unnecessary large nested documents). +- [x] Implement API pagination and limit dataset sizes returned to the client. +- [ ] Introduce caching strategies (e.g., Redis) for frequently accessed, strictly read-only data. +- [ ] Profile expensive API routes and optimize heavy computational logic. +- [ ] Audit WebSocket (socket.io) event payloads and frequency to prevent network saturation. + +## Network & Infrastructure +- [ ] Verify HTTP gzip/brotli compression is enabled on the server. +- [ ] Setup application performance monitoring (APM) to track response times. +- [ ] Review Docker image sizes and optimize `Dockerfile` build processes. diff --git a/backend/controllers/inspirationController.js b/backend/controllers/inspirationController.js index 2177969d..745c57aa 100644 --- a/backend/controllers/inspirationController.js +++ b/backend/controllers/inspirationController.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); -const { launchBrowser, scrapeDribbble } = require('../services/scraperService'); +const { getSharedBrowser, scrapeDribbble } = require('../services/scraperService'); +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); const DATA_FILE = path.join(__dirname, '../data/inspiration.json'); @@ -48,9 +49,8 @@ async function getInspiration(req, res) { if (allItems.length < 5 && query) { console.log(`⚠️ Low cache results for "${query}". Triggering live Dribbble fallback...`); - let browser = null; try { - browser = await launchBrowser(); + const browser = await getSharedBrowser(); const dribbbleItems = await scrapeDribbble(browser, query); if (dribbbleItems.length > 0) { @@ -64,9 +64,6 @@ async function getInspiration(req, res) { } } catch (err) { console.error('Live Fallback Error:', err.message); - - } finally { - if (browser) await browser.close(); } } @@ -76,10 +73,14 @@ async function getInspiration(req, res) { [allItems[i], allItems[j]] = [allItems[j], allItems[i]]; } + const { items, pagination } = paginateArray(allItems, req.query); + setPaginationHeaders(res, pagination); + res.json({ ok: true, - count: allItems.length, - items: allItems + count: items.length, + total: allItems.length, + items }); } catch (error) { diff --git a/backend/index.js b/backend/index.js index 32d82a6e..aa906722 100644 --- a/backend/index.js +++ b/backend/index.js @@ -177,9 +177,29 @@ app.get('/', (req, res) => { res.send('API is running...'); }); -server.listen(PORT, '0.0.0.0', () => { - console.log(`🚀 Server successfully started on port ${PORT}`); -}); +const startServer = (port, retriesLeft = 10) => { + const onError = (error) => { + server.off('error', onError); + + if (error.code === 'EADDRINUSE' && retriesLeft > 0) { + console.warn(`⚠️ Port ${port} is in use, trying ${port + 1}...`); + setTimeout(() => startServer(port + 1, retriesLeft - 1), 100); + return; + } + + console.error('Failed to start backend server:', error); + process.exit(1); + }; + + server.once('error', onError); + + server.listen(port, '0.0.0.0', () => { + server.off('error', onError); + console.log(`🚀 Server successfully started on port ${port}`); + }); +}; + +startServer(PORT); app.use((err, req, res, next) => { diff --git a/backend/models/Project.js b/backend/models/Project.js index dfd58ca6..f76a99f0 100644 --- a/backend/models/Project.js +++ b/backend/models/Project.js @@ -18,6 +18,8 @@ const projectSchema = new mongoose.Schema( // AI-generated architecture architecture: { type: mongoose.Schema.Types.Mixed, default: null }, + architectureCacheKey: { type: String, default: null }, + architectureAnalyzedAt: { type: Date, default: null }, // Google Meet meetLink: { type: String, default: null }, diff --git a/backend/routes/chatRoutes.js b/backend/routes/chatRoutes.js index eb5335f2..9d1a8dab 100644 --- a/backend/routes/chatRoutes.js +++ b/backend/routes/chatRoutes.js @@ -3,6 +3,7 @@ const router = express.Router(); const mongoose = require('mongoose'); const Message = require('../models/Message'); const verifyToken = require('../middleware/authMiddleware'); +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); // Middleware: reject early if DB not connected const requireDb = (req, res, next) => { @@ -62,11 +63,14 @@ router.get('/conversations', verifyToken, requireDb, async (req, res) => { doc: { $first: '$$ROOT' } }}, { $replaceRoot: { newRoot: '$doc' } }, - { $sort: { createdAt: -1 } }, - { $limit: 100 } + { $sort: { createdAt: -1 } } ]); - res.json(conversations.map(m => ({ ...m, id: String(m._id) }))); + const normalized = conversations.map(m => ({ ...m, id: String(m._id) })); + const { items, pagination } = paginateArray(normalized, req.query, { defaultLimit: 100, maxLimit: 200 }); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { console.error('[ChatRoutes] conversations error:', error); res.status(500).json({ error: error.message }); diff --git a/backend/routes/github.js b/backend/routes/github.js index a45f7d3d..f904c042 100644 --- a/backend/routes/github.js +++ b/backend/routes/github.js @@ -5,6 +5,7 @@ const CryptoJS = require('crypto-js'); const User = require('../models/User'); const verifyToken = require('../middleware/authMiddleware'); const { normalizeDoc } = require('../utils/normalize'); +const { getInstallationAccessToken } = require('../utils/githubAppAuth'); const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; @@ -21,6 +22,28 @@ const decryptToken = (ciphertext) => { return bytes.toString(CryptoJS.enc.Utf8); }; +const githubCache = new Map(); + +const fetchWithEtag = async (url, config, cacheKey) => { + const cached = githubCache.get(cacheKey); + const headers = { ...config.headers }; + if (cached && cached.etag) { + headers['If-None-Match'] = cached.etag; + } + + try { + const res = await axios.get(url, { ...config, headers }); + if (res.headers?.etag) { + githubCache.set(cacheKey, { etag: res.headers.etag, data: res.data }); + } + return res; + } catch (error) { + if (error.response && error.response.status === 304 && cached) { + return { ...error.response, status: 304, data: cached.data }; + } + throw error; + } +}; router.post('/connect', verifyToken, async (req, res) => { const { accessToken } = req.body; @@ -31,12 +54,16 @@ router.post('/connect', verifyToken, async (req, res) => { } try { - const githubResponse = await axios.get('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github.v3+json' - } - }); + const githubResponse = await fetchWithEtag( + 'https://api.github.com/user', + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json' + } + }, + `connect_user_${uid}` + ); const githubUsername = githubResponse.data.login; const encryptedToken = encryptToken(accessToken); @@ -138,12 +165,16 @@ router.post('/callback', verifyToken, async (req, res) => { return res.status(400).json({ message: error_description || 'Failed to exchange code for token' }); } - const userResponse = await axios.get('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${access_token}`, - Accept: 'application/vnd.github.v3+json' - } - }); + const userResponse = await fetchWithEtag( + 'https://api.github.com/user', + { + headers: { + Authorization: `Bearer ${access_token}`, + Accept: 'application/vnd.github.v3+json' + } + }, + `callback_user_${uid}` + ); const githubUser = userResponse.data; const encryptedToken = encryptToken(access_token); @@ -197,7 +228,11 @@ router.get('/repos', verifyToken, async (req, res) => { return res.status(500).json({ message: 'Invalid stored token' }); } - const githubResponse = await axios.get('https://api.github.com/user/repos', { + const page = parseInt(req.query.page) || 1; + const per_page = parseInt(req.query.per_page) || 30; + + const cacheKey = `repos_${uid}_${page}_${per_page}`; + const githubResponse = await fetchWithEtag('https://api.github.com/user/repos', { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github.v3+json' @@ -205,11 +240,15 @@ router.get('/repos', verifyToken, async (req, res) => { params: { sort: 'updated', visibility: 'all', - per_page: 100 + per_page, + page } - }); + }, cacheKey); + + const linkHeader = githubResponse.headers.link; + const hasNextPage = !!(linkHeader && linkHeader.includes('rel="next"')); - res.json(githubResponse.data); + res.json({ repos: githubResponse.data, hasNextPage, page }); } catch (error) { console.error('Error fetching GitHub repos:', error.message); @@ -348,7 +387,29 @@ router.get('/user-repos', verifyToken, async (req, res) => { } try { - const response = await octokit.request('GET /installation/repositories', { per_page: 100 }); + const page = parseInt(req.query.page) || 1; + const per_page = parseInt(req.query.per_page) || 30; + + const cacheKey = `user_repos_${uid}_${page}_${per_page}`; + const cached = githubCache.get(cacheKey); + const headers = cached && cached.etag ? { 'If-None-Match': cached.etag } : {}; + + let response; + try { + response = await octokit.request('GET /installation/repositories', { per_page, page, headers }); + if (response.headers?.etag) { + githubCache.set(cacheKey, { etag: response.headers.etag, data: response.data }); + } + } catch (err) { + if (err.status === 304 && cached) { + response = { headers: err.response?.headers || {}, data: cached.data }; + } else { + throw err; + } + } + + const linkHeader = response.headers?.link; + const hasNextPage = !!(linkHeader && linkHeader.includes('rel="next"')); const repos = response.data.repositories.map(repo => ({ id: repo.id, @@ -359,7 +420,7 @@ router.get('/user-repos', verifyToken, async (req, res) => { html_url: repo.html_url })); - res.json(repos); + res.json({ repos, hasNextPage, page }); } catch (requestErr) { console.error("Error fetching repositories from GitHub:", requestErr.message); await disconnectGithub(uid, { installationId: null }); @@ -401,12 +462,13 @@ router.get('/stats', verifyToken, async (req, res) => { const accessToken = decryptToken(github.accessToken); const username = github.username; - const userResponse = await axios.get(`https://api.github.com/users/${username}`, { + const cacheKey = `stats_${username}`; + const userResponse = await fetchWithEtag(`https://api.github.com/users/${username}`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github.v3+json' } - }); + }, cacheKey); res.json({ login: userResponse.data.login, @@ -440,12 +502,13 @@ router.get('/events', verifyToken, async (req, res) => { const accessToken = decryptToken(github.accessToken); const username = github.username; - const eventsResponse = await axios.get(`https://api.github.com/users/${username}/events?per_page=30`, { + const cacheKey = `events_${username}`; + const eventsResponse = await fetchWithEtag(`https://api.github.com/users/${username}/events?per_page=30`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github.v3+json' } - }); + }, cacheKey); const events = eventsResponse.data.map(event => ({ id: event.id, @@ -561,22 +624,24 @@ router.get('/readme', verifyToken, async (req, res) => { } const installationId = github.installationId; - const appId = process.env.GITHUB_APP_ID; - let privateKey = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, '\n'); - - const { App } = await import('octokit'); - const app = new App({ appId, privateKey }); - const octokit = await app.getInstallationOctokit(parseInt(installationId)); + const accessToken = await getInstallationAccessToken(installationId); + const readmeCacheKey = `readme_${uid}_${owner}_${repo}_${installationId}`; try { - const response = await octokit.request('GET /repos/{owner}/{repo}/readme', { - owner, - repo, - mediaType: { format: 'raw' } - }); + const response = await fetchWithEtag( + `https://api.github.com/repos/${owner}/${repo}/readme`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.raw+json' + } + }, + readmeCacheKey + ); + res.send(response.data); } catch (err) { - if (err.status === 404) { + if (err.response && err.response.status === 404) { return res.send("# No README found"); } throw err; diff --git a/backend/routes/meetRoutes.js b/backend/routes/meetRoutes.js index 17cfd648..2f8e485a 100644 --- a/backend/routes/meetRoutes.js +++ b/backend/routes/meetRoutes.js @@ -8,6 +8,7 @@ const { createInstantMeet } = require('../services/googleMeet'); const { sendZyncEmail } = require('../services/mailer'); const { getMeetingInviteTextVersion, getMeetingEmailHtml } = require('../utils/emailTemplates'); const { normalizeDoc, normalizeDocs } = require('../utils/normalize'); +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); router.get('/user/:uid', verifyToken, async (req, res) => { @@ -51,7 +52,10 @@ router.get('/user/:uid', verifyToken, async (req, res) => { return { ...normalizeDoc(m), status }; }); - res.json(updatedMeetings); + const { items, pagination } = paginateArray(updatedMeetings, req.query, { defaultLimit: 20, maxLimit: 100 }); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { console.error('Error fetching meetings:', error); res.status(500).json({ message: 'Server error' }); diff --git a/backend/routes/noteRoutes.js b/backend/routes/noteRoutes.js index 973df508..f39aa039 100644 --- a/backend/routes/noteRoutes.js +++ b/backend/routes/noteRoutes.js @@ -4,6 +4,7 @@ const Note = require('../models/Note'); const Folder = require('../models/Folder'); const verifyToken = require('../middleware/authMiddleware'); const { normalizeDoc, normalizeDocs } = require('../utils/normalize'); +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); router.post('/folders', verifyToken, async (req, res) => { @@ -47,7 +48,10 @@ router.get('/folders', verifyToken, async (req, res) => { .sort({ createdAt: -1 }) .lean(); - res.json(normalizeDocs(folders)); + const { items, pagination } = paginateArray(normalizeDocs(folders), req.query); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { res.status(500).json({ error: error.message }); } @@ -208,7 +212,10 @@ router.get('/', verifyToken, async (req, res) => { const notes = await Note.find(filter) .sort({ updatedAt: -1 }) .lean(); - res.json(normalizeDocs(notes)); + const { items, pagination } = paginateArray(normalizeDocs(notes), req.query); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { res.status(500).json({ error: error.message }); } diff --git a/backend/routes/projectRoutes.js b/backend/routes/projectRoutes.js index 764205b5..f736e01b 100644 --- a/backend/routes/projectRoutes.js +++ b/backend/routes/projectRoutes.js @@ -12,12 +12,15 @@ const axios = require('axios'); const CryptoJS = require('crypto-js'); const authMiddleware = require('../middleware/authMiddleware'); const { normalizeDoc, normalizeDocs } = require('../utils/normalize'); +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); const { getProjectWithSteps, getProjectsWithSteps } = require('../utils/projectHelper'); const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY_SECONDARY); const MODEL_NAME = "gemini-2.5-flash"; console.log(`[Config] Using Gemini Model: ${MODEL_NAME}`); const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; +const ARCHITECTURE_CACHE_TTL_MS = Number.parseInt(process.env.ARCHITECTURE_CACHE_TTL_MS || '21600000', 10); +const architectureAnalysisCache = new Map(); const decryptToken = (ciphertext) => { @@ -47,6 +50,48 @@ const logDebug = (message) => { console.log(`[DEBUG] ${message}`); }; +const makeArchitectureCacheId = (projectId, repoCacheKey) => `${projectId}:${repoCacheKey}`; + +const getArchitectureFromMemoryCache = (projectId, repoCacheKey) => { + if (!projectId || !repoCacheKey) return null; + const cacheId = makeArchitectureCacheId(projectId, repoCacheKey); + const cacheEntry = architectureAnalysisCache.get(cacheId); + if (!cacheEntry) return null; + + if (cacheEntry.expiresAt <= Date.now()) { + architectureAnalysisCache.delete(cacheId); + return null; + } + + return cacheEntry.architecture; +}; + +const setArchitectureInMemoryCache = (projectId, repoCacheKey, architecture) => { + if (!projectId || !repoCacheKey || !architecture) return; + const cacheId = makeArchitectureCacheId(projectId, repoCacheKey); + architectureAnalysisCache.set(cacheId, { + architecture, + expiresAt: Date.now() + ARCHITECTURE_CACHE_TTL_MS, + }); +}; + +const buildRepoFreshnessKey = async (accessToken, owner, repo) => { + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json' + }; + + const repoRes = await axios.get(`https://api.github.com/repos/${owner}/${repo}`, { headers }); + const repoData = repoRes.data || {}; + + return [ + repoData.full_name || `${owner}/${repo}`, + repoData.default_branch || '', + repoData.pushed_at || '', + repoData.updated_at || '' + ].join('|'); +}; + const fetchRepoContext = async (accessToken, owner, repo) => { logDebug(`Fetching repo context for ${owner}/${repo}`); @@ -198,6 +243,7 @@ router.post('/', authMiddleware, async (req, res) => { router.post('/:id/analyze-architecture', authMiddleware, async (req, res) => { try { const { id } = req.params; + const forceRefresh = req.query.forceRefresh === 'true' || req.body?.forceRefresh === true; const project = await Project.findById(id).lean(); if (!project) return res.status(404).json({ message: 'Project not found' }); @@ -223,6 +269,31 @@ router.post('/:id/analyze-architecture', authMiddleware, async (req, res) => { return res.status(500).json({ message: 'Failed to decrypt GitHub token' }); } + let repoCacheKey = null; + try { + repoCacheKey = await buildRepoFreshnessKey(accessToken, githubRepoOwner, githubRepoName); + } catch (cacheKeyError) { + logDebug(`Failed to build repo freshness key: ${cacheKeyError.message}`); + } + + if (!forceRefresh && repoCacheKey) { + const memoryCachedArch = getArchitectureFromMemoryCache(id, repoCacheKey); + if (memoryCachedArch) { + await Project.updateOne( + { _id: id }, + { $set: { architecture: memoryCachedArch, architectureCacheKey: repoCacheKey } } + ); + const cachedProject = await getProjectWithSteps(id); + return res.json(cachedProject); + } + + if (project.architecture && project.architectureCacheKey === repoCacheKey) { + setArchitectureInMemoryCache(id, repoCacheKey, project.architecture); + const cachedProject = await getProjectWithSteps(id); + return res.json(cachedProject); + } + } + console.log(`Analyzing GitHub Repo: ${githubRepoOwner}/${githubRepoName}...`); const context = await fetchRepoContext(accessToken, githubRepoOwner, githubRepoName); const analyzedArch = await analyzeWithGemini(context, project.name); @@ -230,7 +301,16 @@ router.post('/:id/analyze-architecture', authMiddleware, async (req, res) => { console.log("Analysis Result:", JSON.stringify(analyzedArch, null, 2)); if (analyzedArch && Object.keys(analyzedArch).length > 0) { - await Project.updateOne({ _id: id }, { $set: { architecture: analyzedArch } }); + const updates = { + architecture: analyzedArch, + architectureAnalyzedAt: new Date(), + }; + if (repoCacheKey) { + updates.architectureCacheKey = repoCacheKey; + setArchitectureInMemoryCache(id, repoCacheKey, analyzedArch); + } + + await Project.updateOne({ _id: id }, { $set: updates }); console.log("Project architecture saved successfully."); const updatedProject = await getProjectWithSteps(id); return res.json(updatedProject); @@ -402,8 +482,10 @@ router.get('/', authMiddleware, async (req, res) => { const projectMap = new Map(); [...projects, ...assignedProjects].forEach(p => projectMap.set(p.id, p)); const allProjects = Array.from(projectMap.values()); + const { items, pagination } = paginateArray(allProjects, req.query); + setPaginationHeaders(res, pagination); - res.json(allProjects); + res.json(items); } catch (error) { res.status(500).json({ message: 'Server error', error: error.message }); } @@ -739,7 +821,10 @@ router.get('/tasks/search', authMiddleware, async (req, res) => { }; }); - res.json(results); + const { items, pagination } = paginateArray(results, req.query, { defaultLimit: 10, maxLimit: 50 }); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { console.error('Error searching tasks:', error); res.status(500).json({ message: 'Server error' }); diff --git a/backend/routes/sessionRoutes.js b/backend/routes/sessionRoutes.js index f4108892..e191c130 100644 --- a/backend/routes/sessionRoutes.js +++ b/backend/routes/sessionRoutes.js @@ -3,6 +3,7 @@ const router = express.Router(); const Session = require('../models/Session'); const verifyToken = require('../middleware/authMiddleware'); const { normalizeDoc, normalizeDocs } = require('../utils/normalize'); +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); router.use(verifyToken); @@ -45,7 +46,10 @@ router.post('/batch', async (req, res) => { const sessions = await Session.find({ userId: { $in: userIds } }) .sort({ startTime: -1 }) .lean(); - res.json(normalizeDocs(sessions)); + const { items, pagination } = paginateArray(normalizeDocs(sessions), req.query); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { console.error('Error fetching batch sessions:', error); res.status(500).json({ message: 'Server error' }); @@ -100,7 +104,10 @@ router.get('/:userId', async (req, res) => { const sessions = await Session.find({ userId: req.params.userId }) .sort({ startTime: -1 }) .lean(); - res.json(normalizeDocs(sessions)); + const { items, pagination } = paginateArray(normalizeDocs(sessions), req.query); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { console.error('Error fetching sessions:', error); res.status(500).json({ message: 'Server error' }); diff --git a/backend/routes/teamRoutes.js b/backend/routes/teamRoutes.js index 0e80f238..bc00ffac 100644 --- a/backend/routes/teamRoutes.js +++ b/backend/routes/teamRoutes.js @@ -4,6 +4,7 @@ const verifyToken = require('../middleware/authMiddleware'); const User = require('../models/User'); const Team = require('../models/Team'); const { normalizeDoc, normalizeDocs } = require('../utils/normalize'); +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); const generateInviteCode = async () => { @@ -22,7 +23,10 @@ router.get('/owned', verifyToken, async (req, res) => { const uid = req.user.uid; try { const teams = await Team.find({ ownerId: uid }).lean(); - res.json(normalizeDocs(teams)); + const { items, pagination } = paginateArray(normalizeDocs(teams), req.query); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { console.error('Error fetching owned teams:', error); res.status(500).json({ message: 'Server error' }); @@ -34,7 +38,10 @@ router.get('/mine', verifyToken, async (req, res) => { const uid = req.user.uid; try { const teams = await Team.find({ members: uid }).lean(); - res.json(normalizeDocs(teams)); + const { items, pagination } = paginateArray(normalizeDocs(teams), req.query); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { console.error('Error fetching my teams:', error); res.status(500).json({ message: 'Server error' }); diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index cdec832c..5483fbee 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -7,6 +7,7 @@ const { encrypt } = require('../utils/encryption'); const { sendZyncEmail } = require('../services/mailer'); const { appendRow } = require('../services/sheetLogger'); const { normalizeDoc, normalizeDocs } = require('../utils/normalize'); +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); const { getNewUserRegistrationTemplate, getPhoneVerificationEmailHtml, @@ -174,10 +175,12 @@ router.get('/search', verifyToken, async (req, res) => { ] }) .select('uid displayName email photoURL status lastSeen teamMemberships') - .limit(20) .lean(); - res.json(normalizeDocs(users)); + const { items, pagination } = paginateArray(normalizeDocs(users), req.query, { defaultLimit: 20, maxLimit: 100 }); + setPaginationHeaders(res, pagination); + + res.json(items); } catch (error) { console.error('Error searching users:', error); res.status(500).json({ message: 'Server error during search' }); @@ -330,7 +333,9 @@ router.get('/', verifyToken, async (req, res) => { .sort({ lastSeen: -1 }) .select('-githubIntegration.accessToken -deleteConfirmationCode -deleteConfirmationExpires -phoneVerificationCode -phoneVerificationCodeExpires') .lean(); - return res.status(200).json(normalizeDocs(users)); + const { items, pagination } = paginateArray(normalizeDocs(users), req.query); + setPaginationHeaders(res, pagination); + return res.status(200).json(items); } const relatedUids = new Set(currentUser.connections || []); @@ -351,7 +356,9 @@ router.get('/', verifyToken, async (req, res) => { .select('-githubIntegration.accessToken -deleteConfirmationCode -deleteConfirmationExpires -phoneVerificationCode -phoneVerificationCodeExpires') .sort({ lastSeen: -1 }) .lean(); - res.status(200).json(normalizeDocs(users)); + const { items, pagination } = paginateArray(normalizeDocs(users), req.query); + setPaginationHeaders(res, pagination); + res.status(200).json(items); } catch (error) { console.error('Error fetching users:', error); diff --git a/backend/services/scraperService.js b/backend/services/scraperService.js index 7bf63c4e..5eb0e392 100644 --- a/backend/services/scraperService.js +++ b/backend/services/scraperService.js @@ -13,6 +13,25 @@ const LAUNCH_OPTIONS = { ] }; +const SHARED_BROWSER_IDLE_MS = Number.parseInt(process.env.SHARED_BROWSER_IDLE_MS || '300000', 10); +let sharedBrowser = null; +let sharedBrowserPromise = null; +let sharedBrowserIdleTimer = null; + +function clearSharedBrowserIdleTimer() { + if (sharedBrowserIdleTimer) { + clearTimeout(sharedBrowserIdleTimer); + sharedBrowserIdleTimer = null; + } +} + +function scheduleSharedBrowserClose() { + clearSharedBrowserIdleTimer(); + sharedBrowserIdleTimer = setTimeout(async () => { + await closeSharedBrowser(); + }, SHARED_BROWSER_IDLE_MS); +} + async function launchBrowser() { @@ -32,6 +51,52 @@ async function launchBrowser() { return await puppeteer.launch(options); } +async function getSharedBrowser() { + if (sharedBrowser && sharedBrowser.isConnected()) { + scheduleSharedBrowserClose(); + return sharedBrowser; + } + + if (sharedBrowserPromise) { + return sharedBrowserPromise; + } + + sharedBrowserPromise = launchBrowser() + .then((browser) => { + sharedBrowser = browser; + browser.on('disconnected', () => { + sharedBrowser = null; + clearSharedBrowserIdleTimer(); + }); + scheduleSharedBrowserClose(); + return browser; + }) + .finally(() => { + sharedBrowserPromise = null; + }); + + return sharedBrowserPromise; +} + +async function closeSharedBrowser() { + clearSharedBrowserIdleTimer(); + + if (!sharedBrowser) { + return; + } + + const browserToClose = sharedBrowser; + sharedBrowser = null; + + try { + if (browserToClose.isConnected()) { + await browserToClose.close(); + } + } catch (error) { + console.error('Failed to close shared browser:', error.message); + } +} + async function scrapeLapaNinja(browser, query) { if (!browser) return []; @@ -375,6 +440,8 @@ async function scrapeAwwwards(browser, query) { module.exports = { launchBrowser, + getSharedBrowser, + closeSharedBrowser, scrapeLapaNinja, scrapeGodly, scrapeSiteInspire, diff --git a/backend/utils/pagination.js b/backend/utils/pagination.js new file mode 100644 index 00000000..2ef7019c --- /dev/null +++ b/backend/utils/pagination.js @@ -0,0 +1,67 @@ +function toPositiveInt(value) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function getPagination(query = {}, options = {}) { + const defaultLimit = options.defaultLimit ?? null; + const maxLimit = options.maxLimit ?? 100; + + const page = toPositiveInt(query.page) || 1; + const limit = Math.min( + toPositiveInt(query.limit) || defaultLimit || maxLimit, + maxLimit + ); + + const hasPaginationQuery = toPositiveInt(query.page) !== null || toPositiveInt(query.limit) !== null; + const shouldPaginate = hasPaginationQuery || defaultLimit !== null; + + if (!shouldPaginate) { + return null; + } + + return { + page, + limit, + skip: (page - 1) * limit, + }; +} + +function paginateArray(items, query = {}, options = {}) { + const pagination = getPagination(query, options); + if (!pagination) { + return { items, pagination: null }; + } + + const total = items.length; + const totalPages = total === 0 ? 0 : Math.ceil(total / pagination.limit); + const start = pagination.skip; + const paginatedItems = items.slice(start, start + pagination.limit); + + return { + items: paginatedItems, + pagination: { + page: pagination.page, + limit: pagination.limit, + total, + totalPages, + }, + }; +} + +function setPaginationHeaders(res, pagination) { + if (!pagination) return; + + res.set({ + 'X-Page': String(pagination.page), + 'X-Limit': String(pagination.limit), + 'X-Total-Count': String(pagination.total), + 'X-Total-Pages': String(pagination.totalPages), + }); +} + +module.exports = { + getPagination, + paginateArray, + setPaginationHeaders, +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3cd9a7d2..97980c0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,6 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-email/components": "^1.0.7", "@react-email/tailwind": "^2.0.4", - "@reduxjs/toolkit": "^2.5.0", "@tailwindcss/postcss": "^4.1.18", "@tanstack/query-sync-storage-persister": "^5.96.1", "@tanstack/react-query": "^5.90.21", @@ -9005,32 +9004,6 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -9783,12 +9756,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/builder-vite": { @@ -17151,16 +17119,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -26708,7 +26666,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-persist": { "version": "6.0.0", @@ -26719,15 +26678,6 @@ "redux": ">4.0.0" } }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -26962,12 +26912,6 @@ "node": ">=0.10.0" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", diff --git a/package.json b/package.json index 64a72ee5..64f23857 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-email/components": "^1.0.7", "@react-email/tailwind": "^2.0.4", - "@reduxjs/toolkit": "^2.5.0", "@tailwindcss/postcss": "^4.1.18", "@tanstack/query-sync-storage-persister": "^5.96.1", "@tanstack/react-query": "^5.90.21", diff --git a/src/api/projects.ts b/src/api/projects.ts index 7693bc02..31703f49 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -31,9 +31,9 @@ const getAuthHeaders = async () => { }; }; -export const fetchProjects = async (userId: string): Promise => { +export const fetchProjects = async (): Promise => { const headers = await getAuthHeaders(); - const response = await fetch(`${API_URL}?ownerId=${userId}`, { headers }); + const response = await fetch(API_URL, { headers }); if (!response.ok) {return [];} return response.json(); }; diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 016832e8..02060c00 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -69,7 +69,7 @@ const NoteEditor: React.FC = ({ note, user, onUpdate, className useEffect(() => { if (user.uid) { - fetchProjects(user.uid).then(setProjects).catch(e => console.error(e)); + fetchProjects().then(setProjects).catch(e => console.error(e)); } }, [user.uid]); diff --git a/src/components/notes/NotesLayout.tsx b/src/components/notes/NotesLayout.tsx index 0558e9b9..ac03ef73 100644 --- a/src/components/notes/NotesLayout.tsx +++ b/src/components/notes/NotesLayout.tsx @@ -426,8 +426,9 @@ export const NotesLayout: React.FC = ({ user, users = [], init useEffect(() => { if (!user?.uid) {return;} - const sharedFolderIds = folders - .filter(f => f.ownerId !== user.uid && f.collaborators?.includes(user.uid)) + const safeFolders = Array.isArray(folders) ? folders : []; + const sharedFolderIds = safeFolders + .filter(f => f.ownerId !== user.uid && Array.isArray(f.collaborators) && f.collaborators.includes(user.uid)) .map(f => f.id) .slice(0, 10); @@ -458,18 +459,20 @@ export const NotesLayout: React.FC = ({ user, users = [], init // Filtered notes const filteredNotes = useMemo(() => { - if (!searchQuery.trim()) {return notes;} + const safeNotes = Array.isArray(notes) ? notes : []; + if (!searchQuery.trim()) {return safeNotes;} const q = searchQuery.toLowerCase(); - return notes.filter(n => + return safeNotes.filter(n => n.title?.toLowerCase().includes(q) || JSON.stringify(n.content)?.toLowerCase().includes(q) ); }, [notes, searchQuery]); - // Categorized data - const myFolders = folders.filter(f => f.ownerId === user?.uid); - const sharedFolders = folders.filter(f => f.ownerId !== user?.uid); - const myUnorganizedNotes = filteredNotes.filter(n => !n.folderId && n.ownerId === user?.uid); + const safeFolders = Array.isArray(folders) ? folders : []; + const safeFilteredNotes = Array.isArray(filteredNotes) ? filteredNotes : []; + const myFolders = safeFolders.filter(f => f.ownerId === user?.uid); + const sharedFolders = safeFolders.filter(f => f.ownerId !== user?.uid); + const myUnorganizedNotes = safeFilteredNotes.filter(n => !n.folderId && n.ownerId === user?.uid); // Handlers const handleCreateNote = async (folderId?: string) => { diff --git a/src/components/views/ActivityLogView.tsx b/src/components/views/ActivityLogView.tsx index 88f50b0c..30355efd 100644 --- a/src/components/views/ActivityLogView.tsx +++ b/src/components/views/ActivityLogView.tsx @@ -408,7 +408,7 @@ export default function ActivityLogView({ legend: { display: false }, tooltip: { callbacks: { - label: (ctx) => { + label: (ctx: any) => { const v = Number(ctx.raw) || 0; const m = Math.round(v / 60); return `${ctx.label}: ${m} min`; @@ -469,7 +469,7 @@ export default function ActivityLogView({ ticks: { color: T.text3, font: { family: 'DM Mono', size: 10 }, - callback: (v) => `${v}m`, + callback: (v: any) => `${v}m`, }, }, }, diff --git a/src/components/views/CalendarView.tsx b/src/components/views/CalendarView.tsx index a3db1c65..20a6bf6e 100644 --- a/src/components/views/CalendarView.tsx +++ b/src/components/views/CalendarView.tsx @@ -82,7 +82,7 @@ const CalendarView = () => { const user = auth.currentUser; if (user) { try { - const projects = await fetchProjects(user.uid); + const projects = await fetchProjects(); projectEvents = projects.map(p => ({ title: `🚀 Project: ${p.name}`, start: new Date(p.createdAt), diff --git a/src/components/views/DesktopView.tsx b/src/components/views/DesktopView.tsx index 1a1ee084..70ae4f6e 100644 --- a/src/components/views/DesktopView.tsx +++ b/src/components/views/DesktopView.tsx @@ -434,7 +434,7 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => { const fetchLeaderTasks = async () => { try { const token = await currentUser.getIdToken(); - const response = await fetch(`${API_BASE_URL}/api/projects?ownerId=${currentUser.uid}`, { + const response = await fetch(`${API_BASE_URL}/api/projects`, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { diff --git a/src/components/views/MyProjectsView.tsx b/src/components/views/MyProjectsView.tsx index 66ca0331..53d9ad7d 100644 --- a/src/components/views/MyProjectsView.tsx +++ b/src/components/views/MyProjectsView.tsx @@ -28,10 +28,14 @@ const MyProjectsView = ({ currentUser }: { currentUser: any }) => { const { data: userData, isLoading: userLoading } = useMe(); const isConnected = userData?.githubIntegration?.connected; + const [page, setPage] = useState(1); const { - data: repos = [], + data: reposData, isLoading: reposLoading - } = useGitHubRepos(!!isConnected); + } = useGitHubRepos(!!isConnected, page); + + const repos = reposData?.repos || []; + const hasNextPage = reposData?.hasNextPage || false; const loading = userLoading || reposLoading; const [searchTerm, setSearchTerm] = useState(""); @@ -267,6 +271,26 @@ const MyProjectsView = ({ currentUser }: { currentUser: any }) => { })} )} + + {isConnected && !loading && (repos.length > 0 || page > 1) && ( +
+ + Page {page} + +
+ )} ); }; diff --git a/src/components/views/TasksView.tsx b/src/components/views/TasksView.tsx index eb6c44d7..37afc8db 100644 --- a/src/components/views/TasksView.tsx +++ b/src/components/views/TasksView.tsx @@ -71,7 +71,7 @@ const TasksView = ({ currentUser, users = [] }: TasksViewProps) => { const loadTasks = async () => { if (!currentUser?.uid) {return;} try { - const fetchedProjects = await fetchProjects(currentUser.uid); + const fetchedProjects = await fetchProjects(); const groups: Record = {}; fetchedProjects.forEach(p => { diff --git a/src/components/workspace/RepositorySelector.tsx b/src/components/workspace/RepositorySelector.tsx index d150701d..b114b1a6 100644 --- a/src/components/workspace/RepositorySelector.tsx +++ b/src/components/workspace/RepositorySelector.tsx @@ -28,7 +28,7 @@ export function RepositorySelector({ projectId, currentRepoIds = [] }: { project const token = await import('@/lib/firebase').then(m => m.auth.currentUser?.getIdToken()); if (!token) {return;} - const res = await fetch(`${API_BASE_URL}/api/github/repos`, { + const res = await fetch(`${API_BASE_URL}/api/github/repos?per_page=100`, { headers: { Authorization: `Bearer ${token}` } }); diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index b4289959..67a66f5e 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -92,12 +92,22 @@ export interface Repository { }; } -export const useGitHubRepos = (enabled: boolean) => { - return useQuery({ - queryKey: ['github', 'repos'], +export interface GitHubReposResponse { + repos: Repository[]; + hasNextPage: boolean; + page: number; +} + +export const useGitHubRepos = (enabled: boolean, page: number = 1) => { + return useQuery({ + queryKey: ['github', 'repos', page], queryFn: async () => { - const data = await fetchWithAuth(`${API_BASE_URL}/api/github/repos`); - return data.repos || data; + const data = await fetchWithAuth(`${API_BASE_URL}/api/github/repos?page=${page}`); + return { + repos: data.repos || (Array.isArray(data) ? data : []), + hasNextPage: data.hasNextPage || false, + page: data.page || page + }; }, enabled, }); diff --git a/src/hooks/useNotePresence.ts b/src/hooks/useNotePresence.ts index 16757935..61ebd7a7 100644 --- a/src/hooks/useNotePresence.ts +++ b/src/hooks/useNotePresence.ts @@ -113,7 +113,8 @@ export const useNotePresence = ( const now = Date.now(); - const filteredUsers = users.filter(u => { + const usersArray = (Array.isArray(users) ? users : Object.values(users || {})) as ActiveUser[]; + const filteredUsers = usersArray.filter(u => { const isSelf = u.id === user.uid; const isStale = (now - u.lastActive) >= 60000; @@ -141,7 +142,7 @@ export const useNotePresence = ( socket.on('user_left', (userId: string) => { - setActiveUsers(prev => prev.filter(u => u.id !== userId)); + setActiveUsers(prev => (Array.isArray(prev) ? prev : []).filter(u => u.id !== userId)); setRemoteCursors(prev => { const updated = { ...prev }; @@ -160,7 +161,8 @@ export const useNotePresence = ( console.log('📡 [NotePresence] Received cursor_update:', { userId, blockId }); setActiveUsers(prev => { - const updatedUsers = prev.map(u => + const prevArray = Array.isArray(prev) ? prev : []; + const updatedUsers = prevArray.map(u => u.id === userId ? { ...u, blockId, lastActive: Date.now() } : u ); @@ -218,7 +220,7 @@ export const useNotePresence = ( }, [remoteCursors]); - const collaborators: Collaborator[] = activeUsers.map(u => ({ + const collaborators: Collaborator[] = (Array.isArray(activeUsers) ? activeUsers : []).map(u => ({ ...u, odId: u.id, displayName: u.name, diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index b75164c4..25ef02b9 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -1,35 +1,13 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { API_BASE_URL } from "@/lib/utils"; import { auth } from "@/lib/firebase"; - -export interface Project { - _id: string; - name: string; - description: string; - ownerId: string; - createdAt: string; - githubRepoName?: string; - githubRepoOwner?: string; - isTrackingActive?: boolean; -} - -const fetchProjects = async (userId: string): Promise => { - const user = auth.currentUser; - const token = await user?.getIdToken(); - const response = await fetch(`${API_BASE_URL}/api/projects?userId=${userId}`, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - if (!response.ok) {throw new Error('Failed to fetch projects');} - return response.json(); -}; +import { fetchProjects, type Project } from "@/api/projects"; export const useProjects = () => { const user = auth.currentUser; return useQuery({ queryKey: ['projects', user?.uid], - queryFn: () => fetchProjects(user?.uid || ""), + queryFn: fetchProjects, enabled: !!user, refetchOnMount: false, }); diff --git a/src/hooks/useSyncData.ts b/src/hooks/useSyncData.ts index da0cff3e..9ccd513e 100644 --- a/src/hooks/useSyncData.ts +++ b/src/hooks/useSyncData.ts @@ -5,20 +5,16 @@ import { db } from "../lib/db"; import { auth } from "@/lib/firebase"; import { API_BASE_URL } from "@/lib/utils"; import { onAuthStateChanged } from "firebase/auth"; +import { fetchProjects } from "@/api/projects"; // --- API helpers --- -async function fetchSyncData(userId: string, token: string) { - const [userRes, projRes] = await Promise.all([ - fetch(`${API_BASE_URL}/api/users/me`, { - headers: { Authorization: `Bearer ${token}` }, - }), - fetch(`${API_BASE_URL}/api/projects?userId=${userId}`, { - headers: { Authorization: `Bearer ${token}` }, - }), - ]); +async function fetchSyncData(token: string) { + const userRes = await fetch(`${API_BASE_URL}/api/users/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); const user = userRes.ok ? await userRes.json() : null; - const projects = projRes.ok ? await projRes.json() : []; + const projects = await fetchProjects(); return { user, projects }; } @@ -80,7 +76,7 @@ export function useSyncData() { if (!currentUser || !userId) {return null;} console.log(`[Sync] Fetching new user data from backend for ${userId} in the background...`); const token = await currentUser.getIdToken(); - const result = await fetchSyncData(userId, token); + const result = await fetchSyncData(token); console.log("[Sync] Received new data from backend:", result); return result; }, diff --git a/ts_errors.out b/ts_errors.out new file mode 100644 index 00000000..e69de29b