From 9a8178e9ca7f78010199640174152a76551e0291 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Thu, 26 Mar 2026 14:12:04 +0300 Subject: [PATCH] refactor(backend): move user routes to a module; add packages for cross-spawn types --- package-lock.json | 11 ++ package.json | 1 + server/src/modules/user/.gitkeep | 1 - server/src/modules/user/user.routes.ts | 137 +++++++++++++++++++++++++ server/src/runner.ts | 6 ++ server/src/shared/utils/git-config.ts | 60 +++++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) delete mode 100644 server/src/modules/user/.gitkeep create mode 100644 server/src/modules/user/user.routes.ts create mode 100644 server/src/shared/utils/git-config.ts diff --git a/package-lock.json b/package-lock.json index cf8c1489..0185eb06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", + "@types/cross-spawn": "^6.0.6", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.19.7", @@ -3791,6 +3792,16 @@ "@types/node": "*" } }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", diff --git a/package.json b/package.json index a6c90041..8373b11c 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", + "@types/cross-spawn": "^6.0.6", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.19.7", diff --git a/server/src/modules/user/.gitkeep b/server/src/modules/user/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/user/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/user/user.routes.ts b/server/src/modules/user/user.routes.ts new file mode 100644 index 00000000..cb35469c --- /dev/null +++ b/server/src/modules/user/user.routes.ts @@ -0,0 +1,137 @@ +import express, { type Response } from 'express'; +import { userDb } from '@/shared/database/repositories/users.js'; +import { authenticateToken } from '@/modules/auth/auth.middleware.js'; +import { getSystemGitConfig, spawnAsync } from '@/shared/utils/git-config.js'; +import type { AuthenticatedRequest } from '@/shared/types/http.js'; +import { logger } from '@/shared/utils/logger.js'; + +export const userRoutes = express.Router(); + +/** + * Get the user's git config. + * Falls back to system's global git config if not set in the DB. + */ +userRoutes.get('/git-config', authenticateToken, async (req: AuthenticatedRequest, res: Response): Promise => { + try { + if (!req.user || !req.user.id) { + res.status(401).json({ error: 'User not authenticated' }); + return; + } + + const userId = Number(req.user.id); + let gitConfig = userDb.getGitConfig(userId); + + // If database is empty, try to get from system git config + if (!gitConfig || (!gitConfig.git_name && !gitConfig.git_email)) { + const systemConfig = await getSystemGitConfig(); + + // If system has values, save them to database for this user + if (systemConfig.git_name || systemConfig.git_email) { + userDb.updateGitConfig(userId, systemConfig.git_name || '', systemConfig.git_email || ''); + gitConfig = systemConfig; + logger.info(`Auto-populated git config from system for user ${userId}: ${systemConfig.git_name} <${systemConfig.git_email}>`); + } + } + + res.json({ + success: true, + gitName: gitConfig?.git_name || null, + gitEmail: gitConfig?.git_email || null + }); + } catch (error) { + logger.error('Error getting git config:', { error }); + res.status(500).json({ error: 'Failed to get git configuration' }); + } +}); + +/** + * Apply git config globally via git config --global and save to DB + */ +userRoutes.post('/git-config', authenticateToken, async (req: AuthenticatedRequest, res: Response): Promise => { + try { + if (!req.user || !req.user.id) { + res.status(401).json({ error: 'User not authenticated' }); + return; + } + + const userId = Number(req.user.id); + const { gitName, gitEmail } = req.body; + + if (!gitName || !gitEmail) { + res.status(400).json({ error: 'Git name and email are required' }); + return; + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(gitEmail)) { + res.status(400).json({ error: 'Invalid email format' }); + return; + } + + userDb.updateGitConfig(userId, gitName, gitEmail); + + try { + await spawnAsync('git', ['config', '--global', 'user.name', String(gitName)]); + await spawnAsync('git', ['config', '--global', 'user.email', String(gitEmail)]); + logger.info(`Applied git config globally: ${gitName} <${gitEmail}>`); + } catch (gitError) { + logger.error('Error applying git config:', { error: gitError }); + } + + res.json({ + success: true, + gitName, + gitEmail + }); + } catch (error) { + logger.error('Error updating git config:', { error }); + res.status(500).json({ error: 'Failed to update git configuration' }); + } +}); + +/** + * Complete onboarding for the user + */ +userRoutes.post('/complete-onboarding', authenticateToken, async (req: AuthenticatedRequest, res: Response): Promise => { + try { + if (!req.user || !req.user.id) { + res.status(401).json({ error: 'User not authenticated' }); + return; + } + + const userId = Number(req.user.id); + userDb.completeOnboarding(userId); + + res.json({ + success: true, + message: 'Onboarding completed successfully' + }); + } catch (error) { + logger.error('Error completing onboarding:', { error }); + res.status(500).json({ error: 'Failed to complete onboarding' }); + } +}); + +/** + * Get onboarding status for the user + */ +userRoutes.get('/onboarding-status', authenticateToken, async (req: AuthenticatedRequest, res: Response): Promise => { + try { + if (!req.user || !req.user.id) { + res.status(401).json({ error: 'User not authenticated' }); + return; + } + + const userId = Number(req.user.id); + const hasCompleted = userDb.hasCompletedOnboarding(userId); + + res.json({ + success: true, + hasCompletedOnboarding: hasCompleted + }); + } catch (error) { + logger.error('Error checking onboarding status:', { error }); + res.status(500).json({ error: 'Failed to check onboarding status' }); + } +}); diff --git a/server/src/runner.ts b/server/src/runner.ts index fad562ed..63253a67 100644 --- a/server/src/runner.ts +++ b/server/src/runner.ts @@ -12,6 +12,8 @@ import { initializeWatcher } from '@/modules/sessions/sessions.watcher.js'; import { getConnectableHost } from '@/shared/utils/networkHosts.js'; import { logger } from '@/shared/utils/logger.js'; import { authRoutes } from '@/modules/auth/auth.routes.js'; +import { userRoutes } from '@/modules/user/user.routes.js'; +import { authenticateToken } from '@/modules/auth/auth.middleware.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -58,8 +60,12 @@ app.use((req, res, next) => { }); +// Authentication routes (public) app.use('/api/auth', authRoutes); +// User API Routes (protected) +app.use('/api/user', authenticateToken, userRoutes); + // This matches files found in the root public folder (like api-docs.html when we run `/api-docs.html`). // If the file is found, it's automatically sent. If it is not, it passes it to the next route checker. // This will run in production as well as development URLs. diff --git a/server/src/shared/utils/git-config.ts b/server/src/shared/utils/git-config.ts new file mode 100644 index 00000000..b9083b0a --- /dev/null +++ b/server/src/shared/utils/git-config.ts @@ -0,0 +1,60 @@ +import spawn from 'cross-spawn'; +import type { SpawnOptionsWithoutStdio } from 'child_process'; + +type SpawnResult = { + stdout: string; + stderr: string; +}; + +export function spawnAsync(command: string, args: string[], options: SpawnOptionsWithoutStdio = {}): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { ...options, shell: false }); + let stdout = ''; + let stderr = ''; + + if (child.stdout) { + child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); + } + if (child.stderr) { + child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + } + + child.on('error', (error: Error) => { reject(error); }); + + child.on('close', (code: number | null) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + const error = new Error(`Command failed: ${command} ${args.join(' ')}`) as Error & { + code: number | null; + stdout: string; + stderr: string; + }; + error.code = code; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }); + }); +} + +/** + * Read git configuration from system's global git config + */ +export async function getSystemGitConfig(): Promise<{ git_name: string | null; git_email: string | null }> { + try { + const [nameResult, emailResult] = await Promise.all([ + spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '', stderr: '' })), + spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '', stderr: '' })) + ]); + + return { + git_name: nameResult.stdout.trim() || null, + git_email: emailResult.stdout.trim() || null + }; + } catch (error) { + return { git_name: null, git_email: null }; + } +}