Skip to content
Open
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
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ A modern, full-stack todo application built with Node.js, Express, and SQLite. F
- 🚀 **REST API**: Clean, well-documented API endpoints
- 📱 **Mobile Responsive**: Works perfectly on all device sizes
- ⚡ **Real-time Updates**: Instant UI updates without page refresh
- 📝 **Activity Tracking**: Complete audit log of all user actions
- 🔍 **Activity Analytics**: Detailed statistics and activity monitoring

## Tech Stack

Expand Down Expand Up @@ -77,6 +79,17 @@ A modern, full-stack todo application built with Node.js, Express, and SQLite. F
| PUT | `/api/todos/:id` | Update a todo | `{ "title": "string", "description": "string", "completed": boolean }` |
| DELETE | `/api/todos/:id` | Delete a todo | - |

### Activities

| Method | Endpoint | Description | Query Parameters |
| ------ | -------------------------- | -------------------------- | -------------------------- |
| GET | `/api/activities` | Get all activities | `page`, `limit`, `todo_id` |
| GET | `/api/activities/stats` | Get activity statistics | - |
| GET | `/api/activities/todo/:id` | Get activities for a todo | - |
| GET | `/api/activities/:id` | Get a specific activity | - |
| DELETE | `/api/activities/:id` | Delete a specific activity | - |
| DELETE | `/api/activities` | Clear all activities | - |

### Example API Usage

**Create a new todo:**
Expand Down Expand Up @@ -107,11 +120,36 @@ curl -X PUT http://localhost:3000/api/todos/1 \
curl -X DELETE http://localhost:3000/api/todos/1
```

**Get all activities:**

```bash
curl http://localhost:3000/api/activities
```

**Get activities with pagination:**

```bash
curl "http://localhost:3000/api/activities?page=1&limit=10"
```

**Get activities for a specific todo:**

```bash
curl http://localhost:3000/api/activities/todo/1
```

**Get activity statistics:**

```bash
curl http://localhost:3000/api/activities/stats
```

## Database Schema

The application uses SQLite with the following schema:

```sql
-- Todos table
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
Expand All @@ -120,6 +158,20 @@ CREATE TABLE todos (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- Activities table for audit logging
CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
todo_id INTEGER,
action TEXT NOT NULL,
description TEXT,
old_value TEXT,
new_value TEXT,
user_ip TEXT,
user_agent TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (todo_id) REFERENCES todos (id) ON DELETE SET NULL
);
```

## Project Structure
Expand All @@ -129,9 +181,11 @@ coderabbit-review/
├── config/ # Configuration files
│ └── database.js # Database connection and setup
├── controllers/ # Business logic controllers
│ └── todoController.js # Todo-related controller functions
│ ├── todoController.js # Todo-related controller functions
│ └── activityController.js # Activity tracking controller
├── routes/ # API route definitions
│ └── todoRoutes.js # Todo API routes
│ ├── todoRoutes.js # Todo API routes
│ └── activityRoutes.js # Activity API routes
├── public/ # Frontend files
│ ├── index.html # Main HTML file
│ ├── style.css # CSS styles
Expand Down
38 changes: 32 additions & 6 deletions config/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class Database {

initializeTables() {
return new Promise((resolve, reject) => {
const createTableSQL = `
const createTodosTableSQL = `
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
Expand All @@ -34,14 +34,40 @@ class Database {
)
`;

this.db.run(createTableSQL, (err) => {
const createActivityTableSQL = `
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
todo_id INTEGER,
action TEXT NOT NULL,
description TEXT,
old_value TEXT,
new_value TEXT,
user_ip TEXT,
user_agent TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (todo_id) REFERENCES todos (id) ON DELETE SET NULL
)
`;

// Create todos table
this.db.run(createTodosTableSQL, (err) => {
if (err) {
console.error('Error creating table:', err.message);
console.error('Error creating todos table:', err.message);
reject(err);
} else {
console.log('Todos table ready');
resolve();
return;
}
console.log('Todos table ready');

// Create activities table
this.db.run(createActivityTableSQL, (err) => {
if (err) {
console.error('Error creating activities table:', err.message);
reject(err);
return;
}
console.log('Activities table ready');
resolve();
});
});
});
}
Expand Down
239 changes: 239 additions & 0 deletions controllers/activityController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
const database = require('../config/database');

class ActivityController {
// Get all activities
async getAllActivities(req, res) {
try {
const db = database.getConnection();
const { page = 1, limit = 50, todo_id } = req.query;
const offset = (page - 1) * limit;

let sql = `
SELECT
a.*,
t.title as todo_title
FROM activities a
LEFT JOIN todos t ON a.todo_id = t.id
`;
let params = [];

if (todo_id) {
sql += ' WHERE a.todo_id = ?';
params.push(todo_id);
}

sql += ' ORDER BY a.created_at DESC LIMIT ? OFFSET ?';
params.push(parseInt(limit), parseInt(offset));

db.all(sql, params, (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}

// Get total count for pagination
let countSql = 'SELECT COUNT(*) as total FROM activities';
let countParams = [];

if (todo_id) {
countSql += ' WHERE todo_id = ?';
countParams.push(todo_id);
}

db.get(countSql, countParams, (err, countResult) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}

res.json({
activities: rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: countResult.total,
pages: Math.ceil(countResult.total / limit)
}
});
});
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}
Comment on lines +5 to +63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add input validation for pagination parameters.

The pagination parameters are parsed directly without validation, which could lead to negative offsets or invalid limits.

Add validation for the pagination parameters:

  async getAllActivities(req, res) {
    try {
      const db = database.getConnection();
-      const { page = 1, limit = 50, todo_id } = req.query;
+      let { page = 1, limit = 50, todo_id } = req.query;
+      
+      // Validate and sanitize pagination parameters
+      page = Math.max(1, parseInt(page) || 1);
+      limit = Math.min(100, Math.max(1, parseInt(limit) || 50));
+      
       const offset = (page - 1) * limit;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async getAllActivities(req, res) {
try {
const db = database.getConnection();
const { page = 1, limit = 50, todo_id } = req.query;
const offset = (page - 1) * limit;
let sql = `
SELECT
a.*,
t.title as todo_title
FROM activities a
LEFT JOIN todos t ON a.todo_id = t.id
`;
let params = [];
if (todo_id) {
sql += ' WHERE a.todo_id = ?';
params.push(todo_id);
}
sql += ' ORDER BY a.created_at DESC LIMIT ? OFFSET ?';
params.push(parseInt(limit), parseInt(offset));
db.all(sql, params, (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
// Get total count for pagination
let countSql = 'SELECT COUNT(*) as total FROM activities';
let countParams = [];
if (todo_id) {
countSql += ' WHERE todo_id = ?';
countParams.push(todo_id);
}
db.get(countSql, countParams, (err, countResult) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({
activities: rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: countResult.total,
pages: Math.ceil(countResult.total / limit)
}
});
});
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}
async getAllActivities(req, res) {
try {
const db = database.getConnection();
let { page = 1, limit = 50, todo_id } = req.query;
// Validate and sanitize pagination parameters
page = Math.max(1, parseInt(page, 10) || 1);
limit = Math.min(100, Math.max(1, parseInt(limit, 10) || 50));
const offset = (page - 1) * limit;
// … rest of method remains unchanged …
🤖 Prompt for AI Agents
In controllers/activityController.js around lines 5 to 63, the pagination query
params (page, limit) are parsed without validation which can produce negative
offsets or invalid limits; validate and normalize these inputs by parsing them
with parseInt(base 10), treating NaN or values < 1 as the default (page = 1,
limit = 50), enforce an upper bound on limit (e.g., maxLimit = 100) and use
Math.max to ensure page and limit are at least 1, then recompute offset = (page
- 1) * limit using the validated values; also validate/convert todo_id if
provided (ensure it’s an integer or ignore/return a 400) and use the validated
params in the SQL params and pagination metadata.


// Get activities for a specific todo
async getActivitiesByTodoId(req, res) {
try {
const { todoId } = req.params;
const db = database.getConnection();

const sql = `
SELECT
a.*,
t.title as todo_title
FROM activities a
LEFT JOIN todos t ON a.todo_id = t.id
WHERE a.todo_id = ?
ORDER BY a.created_at DESC
`;

db.all(sql, [todoId], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows);
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}

// Get a single activity by id
async getActivityById(req, res) {
try {
const { id } = req.params;
const db = database.getConnection();

const sql = `
SELECT
a.*,
t.title as todo_title
FROM activities a
LEFT JOIN todos t ON a.todo_id = t.id
WHERE a.id = ?
`;

db.get(sql, [id], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
if (!row) {
res.status(404).json({ error: 'Activity not found' });
return;
}
res.json(row);
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}

// Create a new activity (usually called internally)
async createActivity(activityData) {
return new Promise((resolve, reject) => {
const db = database.getConnection();
const {
todo_id,
action,
description,
old_value,
new_value,
user_ip,
user_agent
} = activityData;

const sql = `
INSERT INTO activities (
todo_id, action, description, old_value, new_value, user_ip, user_agent
) VALUES (?, ?, ?, ?, ?, ?, ?)
`;

db.run(sql, [
todo_id,
action,
description,
old_value,
new_value,
user_ip,
user_agent
], function(err) {
if (err) {
reject(err);
} else {
resolve(this.lastID);
}
});
});
}

// Delete an activity
async deleteActivity(req, res) {
try {
const { id } = req.params;
const db = database.getConnection();

db.run('DELETE FROM activities WHERE id = ?', [id], function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Activity not found' });
return;
}
res.json({ message: 'Activity deleted successfully' });
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}

// Clear all activities (admin function)
async clearAllActivities(req, res) {
try {
const db = database.getConnection();

db.run('DELETE FROM activities', function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({
message: 'All activities cleared successfully',
deletedCount: this.changes
});
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}
Comment on lines +184 to +202
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Security concern: clearAllActivities lacks authorization check.

The clearAllActivities method is destructive and should require proper authorization before allowing deletion of all audit logs.

This endpoint should verify admin privileges before execution. Consider adding middleware for authorization:

// In your route definition, add authorization middleware:
router.delete('/', requireAdmin, activityController.clearAllActivities);

Also consider adding a confirmation mechanism to prevent accidental deletion.

🤖 Prompt for AI Agents
In controllers/activityController.js around lines 184 to 202, the
clearAllActivities handler is missing any authorization or confirmation before
performing a destructive delete; add an authorization check at the start of the
function (e.g., verify req.user exists and that req.user.role === 'admin' or
req.user.isAdmin) and return res.status(403) if unauthorized, and require an
explicit confirmation flag in the request (for example a boolean confirm=true in
req.body or a specific admin token) before running the DELETE; alternatively
ensure the route that wires this controller uses a requireAdmin middleware
(router.delete('/', requireAdmin, activityController.clearAllActivities)) and
reject requests without the confirmation parameter to prevent accidental
deletion.


// Get activity statistics
async getActivityStats(req, res) {
try {
const db = database.getConnection();

const statsQueries = [
'SELECT COUNT(*) as total FROM activities',
'SELECT COUNT(*) as today FROM activities WHERE DATE(created_at) = DATE("now")',
'SELECT action, COUNT(*) as count FROM activities GROUP BY action',
'SELECT DATE(created_at) as date, COUNT(*) as count FROM activities GROUP BY DATE(created_at) ORDER BY date DESC LIMIT 7'
];

Promise.all(statsQueries.map(query =>
new Promise((resolve, reject) => {
db.all(query, [], (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
})
)).then(([total, today, byAction, byDate]) => {
res.json({
total: total[0].total,
today: today[0].today,
byAction: byAction,
last7Days: byDate
});
}).catch(err => {
res.status(500).json({ error: err.message });
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}
}

module.exports = new ActivityController();
Loading