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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ JWT_EXPIRE=7d
# Session Secret (used for Passport sessions)
SESSION_SECRET=<random-64-char-hex-string>

# Cron Secret (used to secure /api/cron/sync external scheduler endpoint)
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
CRON_SECRET=

# GitHub Personal Access Token (optional — increases rate limit from 60/hr to 5000/hr)
# Create at: https://github.com/settings/tokens
# Required scopes: (none for public data) or read:user for contribution calendar
Expand Down
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: CI

on:
push:
branches:
- development
- feature/**
pull_request:
branches:
- main
- development

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Unit & Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'

- name: Install root dependencies
run: npm ci || npm install

- name: Install server dependencies
working-directory: server
run: npm ci || npm install

- name: Run automated test suite
run: npm test
env:
CI: true
NODE_ENV: test
JWT_SECRET: test_jwt_secret_for_ci_environment_devact
SESSION_SECRET: test_session_secret_for_ci_environment_devact
ENCRYPTION_KEY: 12345678901234567890123456789012
FRONTEND_URL: http://localhost:5173
CRON_SECRET: test_cron_secret_for_ci_environment_devact
MONGO_URI: mongodb://localhost:27017/test_devact

build:
name: Frontend Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'

- name: Install root dependencies
run: npm ci || npm install

- name: Install client dependencies
working-directory: client
run: npm ci || npm install

- name: Build frontend
working-directory: client
run: npm run build
39 changes: 39 additions & 0 deletions .github/workflows/sync-cron.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Scheduled Background Sync

on:
schedule:
# Runs every 3 hours (00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00 UTC)
- cron: '0 */3 * * *'
workflow_dispatch:

concurrency:
group: sync-cron
cancel-in-progress: false

jobs:
trigger-sync:
runs-on: ubuntu-latest
steps:
- name: Trigger DevAct Sync Endpoint
run: |
if [ -z "${{ secrets.CRON_SYNC_URL }}" ] || [ -z "${{ secrets.CRON_SECRET }}" ]; then
echo "Error: CRON_SYNC_URL and CRON_SECRET must be configured in repository secrets."
exit 1
fi

echo "Triggering DevAct sync at ${{ secrets.CRON_SYNC_URL }}..."
HTTP_STATUS=$(curl -s -o response.json -w "%{http_code}" -X POST "${{ secrets.CRON_SYNC_URL }}" \
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
-H "Content-Type: application/json" \
--max-time 60)

echo "Response HTTP Status: $HTTP_STATUS"
echo "Response Body:"
cat response.json
echo ""

if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
echo "Cron sync failed with HTTP $HTTP_STATUS"
exit 1
fi
echo "Cron sync completed successfully."
50 changes: 50 additions & 0 deletions api/cron/sync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const connectDB = require('../../server/config/db');
const syncService = require('../../server/services/sync.service');

/**
* Vercel Serverless Function / HTTP endpoint for scheduled background sync.
* Secured via Bearer token matching CRON_SECRET.
*/
async function cronSyncHandler(req, res) {
if (req.method !== 'GET' && req.method !== 'POST') {
return res.status(405).json({ message: 'Method Not Allowed' });
}

const authHeader = req.headers.authorization;
const cronSecret = process.env.CRON_SECRET;

if (!cronSecret) {
console.error('[Cron] CRON_SECRET is not configured');
return res.status(500).json({ message: 'CRON_SECRET is not configured' });
}

if (!authHeader || authHeader !== `Bearer ${cronSecret}`) {
return res.status(401).json({ message: 'Unauthorized' });
}

try {
await connectDB();

const limit = parseInt(req.query?.limit, 10) || 10;
const skipSyncedWithin = parseInt(req.query?.skipSyncedWithin, 10) || 120;

const result = await syncService.syncAllUsers({
limit,
skipSyncedWithinMinutes: skipSyncedWithin,
});

return res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
result,
});
} catch (error) {
console.error('[Cron] Background sync error:', error.message);
return res.status(500).json({
status: 'error',
message: error.message,
});
}
}

module.exports = cronSyncHandler;
96 changes: 7 additions & 89 deletions api/index.js
Original file line number Diff line number Diff line change
@@ -1,99 +1,17 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const { generalLimiter } = require('../server/middleware/rateLimiter');

// Startup Config Check for WakaTime encryption
if (!process.env.ENCRYPTION_KEY || Buffer.from(process.env.ENCRYPTION_KEY).length !== 32) {
console.error('[Config] ENCRYPTION_KEY must be exactly 32 bytes for AES-256-CBC');
}

const connectDB = require('../server/config/db');
const { createApp } = require('../server/app');

// ── Import route handlers ──
const authRoutes = require('../server/routes/auth.routes');
const githubRoutes = require('../server/routes/github.routes');
const cpRoutes = require('../server/routes/cp.routes');
const dashboardRoutes = require('../server/routes/dashboard.routes');
const repoRoutes = require('../server/routes/repo.routes');
const syncRoutes = require('../server/routes/sync.routes');
const snapshotRoutes = require('../server/routes/snapshot.routes');
const publicRoutes = require('../server/routes/public.routes');
const accountRoutes = require('../server/routes/account.routes');
const contestsRoutes = require('../server/routes/contests.routes');
const streakRoutes = require('../server/routes/streak.routes');
const goalsRoutes = require('../server/routes/goals.routes');
const journalRoutes = require('../server/routes/journal.routes');

const session = require('express-session');
const passport = require('../server/config/passport');

const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';

// ── Express App ──
const app = express();
app.use(helmet({
crossOriginResourcePolicy: { policy: "cross-origin" }
}));
app.use(cors({
origin: FRONTEND_URL,
credentials: true,
}));
app.use(express.json());
app.use(cookieParser());

// Apply general rate limiter to all API routes
app.use('/api', generalLimiter);
app.use(async (req, res, next) => {
// ── Per-request DB connection (serverless-safe) ──
const dbMiddleware = async (req, res, next) => {
try {
await connectDB();
next();
} catch (err) {
res.status(500).json({ message: `Database connection failed: ${err.message}` });
res.status(503).json({ message: 'Database connection failed' });
}
});

// Initialize Session and Passport
app.use(
session({
secret: process.env.SESSION_SECRET || process.env.JWT_SECRET || 'devact_session_secret',
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'lax' },
})
);
app.use(passport.initialize());
app.use(passport.session());

// Mount routes
app.use('/api/auth', authRoutes);
app.use('/api/github', githubRoutes);
app.use('/api/cp', cpRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/repos', repoRoutes);
app.use('/api/sync', syncRoutes);
app.use('/api/snapshots', snapshotRoutes);
app.use('/api/u', publicRoutes);
app.use('/api/account', accountRoutes);
app.use('/api/contests', contestsRoutes);
app.use('/api/streak', streakRoutes);
app.use('/api/goals', goalsRoutes);
app.use('/api/journal', journalRoutes);

// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
};

// Error handler
app.use((err, req, res, next) => {
console.error(err.message);
res.status(err.status || 500).json({
message: process.env.NODE_ENV === 'production'
? 'Something went wrong'
: err.message,
});
});
// ── Express App (shared configuration) ──
const app = createApp({ dbMiddleware });

module.exports = app;
4 changes: 2 additions & 2 deletions client/src/components/Navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,12 @@ function Navbar() {
</div>
<button onClick={handleLogout} className="nav-logout-btn">
<LogOut size={14} />
Logout
Sign out
</button>
</>
) : (
<>
<Link to="/login" className={isActive('/login')} onClick={closeMenu}>Login</Link>
<Link to="/login" className={isActive('/login')} onClick={closeMenu}>Sign in</Link>
<Link to="/register" className="btn btn-primary btn-sm" onClick={closeMenu}>Get Started</Link>
</>
)}
Expand Down
4 changes: 2 additions & 2 deletions client/src/pages/CPTracker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function CPTracker() {
{/* Codeforces Section */}
{hasCF && cfStats && (
<>
<h3 className="section-title">Codeforces — {cfStats.userInfo.handle}</h3>
<h3 className="section-title">Codeforces: {cfStats.userInfo.handle}</h3>
<div className="stats-grid">
<div className="stat-card">
<span className="stat-value">{cfStats.userInfo.rating}</span>
Expand Down Expand Up @@ -235,7 +235,7 @@ function CPTracker() {
{/* LeetCode Section */}
{hasLC && lcStats && (
<>
<h3 className="section-title lc-title">LeetCode — {lcStats.username}</h3>
<h3 className="section-title lc-title">LeetCode: {lcStats.username}</h3>
<div className="stats-grid">
<div className="stat-card">
<span className="stat-value">{lcStats.solved.all}</span>
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/Contests.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ function Contests() {
<div className="empty-state">
<Trophy size={40} style={{ color: 'var(--accent-primary)', marginBottom: '1rem' }} />
<h3>No upcoming contests found</h3>
<p style={{ color: 'var(--text-muted)' }}>Check back soon — contests are usually announced a few days in advance.</p>
<p style={{ color: 'var(--text-muted)' }}>Check back soon. Contests are usually announced a few days in advance.</p>
</div>
)}

Expand Down
12 changes: 6 additions & 6 deletions client/src/pages/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function StreakWidget() {
</div>
{!streak.activeToday && (
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: '0.75rem', textAlign: 'center' }}>
No activity yet today — commit, submit, or code to keep your streak alive.
No activity today. Commit, submit, or code to keep your streak alive.
</p>
)}
</div>
Expand Down Expand Up @@ -117,7 +117,7 @@ function GoalsWidget() {
</div>

{goals.length === 0 && !adding && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', textAlign: 'center', padding: '1rem 0' }}>No goals yet — add one to track your daily targets.</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', textAlign: 'center', padding: '1rem 0' }}>No goals yet. Add one to track your daily targets.</p>
)}

<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: adding ? '1rem' : 0 }}>
Expand Down Expand Up @@ -230,7 +230,7 @@ function Dashboard() {
}, 3000);
setTimeout(() => { clearInterval(poll); setSyncing(false); }, 30000);
} catch (err) {
setSyncError(err.response?.data?.message || 'Sync failed — try again.');
setSyncError(err.response?.data?.message || 'Sync failed. Try again.');
setSyncing(false);
}
};
Expand Down Expand Up @@ -287,7 +287,7 @@ function Dashboard() {
)}
{data?.syncing && (
<div className="dashboard-notice" style={{ borderColor: 'rgba(108,142,191,0.3)', background: 'rgba(108,142,191,0.06)' }}>
<span style={{ color: 'var(--accent-blue)', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}><Loader size={14} className="spinning" /> Initial sync in progress — your data will appear shortly.</span>
<span style={{ color: 'var(--accent-blue)', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}><Loader size={14} className="spinning" /> Initial sync in progress. Your data will appear shortly.</span>
</div>
)}
{!hasAny && (
Expand All @@ -298,7 +298,7 @@ function Dashboard() {
padding: '1.5rem',
marginBottom: '1.5rem',
}}>
<p style={{ fontWeight: 700, fontSize: '0.95rem', marginBottom: '0.35rem' }}>Get started — connect your accounts</p>
<p style={{ fontWeight: 700, fontSize: '0.95rem', marginBottom: '0.35rem' }}>Get started: connect your accounts</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '1.25rem' }}>
DevAct can't show you anything until it knows where to pull data from.
</p>
Expand Down Expand Up @@ -591,7 +591,7 @@ function Dashboard() {
) : user?.wakatimeConfiguredAt && !data?.wakatime ? (
<div className="dashboard-card" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{isStalled(user.wakatimeConfiguredAt)
? <p style={{ color: 'var(--error)', fontSize: '0.875rem' }}>WakaTime sync failed — try syncing manually.</p>
? <p style={{ color: 'var(--error)', fontSize: '0.875rem' }}>WakaTime sync failed. Try syncing manually.</p>
: <p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}><Loader size={14} className="spinning" /> Syncing WakaTime…</p>
}
</div>
Expand Down
4 changes: 2 additions & 2 deletions client/src/pages/Home.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const FEATURES = [
icon: <LayoutDashboard size={20} />,
color: 'orange',
title: 'Unified Dashboard',
desc: 'GitHub, CP ratings, coding time, and more — unified in one dashboard.',
desc: 'GitHub, CP ratings, coding time, and more, unified in one dashboard.',
},
];

Expand Down Expand Up @@ -56,7 +56,7 @@ function Home() {
</h1>

<p>
GitHub, Codeforces, LeetCode, WakaTime — pulled together automatically.
GitHub, Codeforces, LeetCode, WakaTime, pulled together automatically.
No manual updates.
</p>

Expand Down
Loading
Loading