feat: Add user tracking to project tasks and notes

- Implemented user tracking columns in project_tasks and notes tables.
- Added created_by and assigned_to fields to project_tasks.
- Introduced created_by field and is_system flag in notes.
- Updated API endpoints to handle user tracking during task and note creation.
- Enhanced database initialization to include new columns and indexes.
- Created utility functions to fetch users for task assignment.
- Updated front-end components to display user information for tasks and notes.
- Added tests for project-tasks API endpoints to verify functionality.
This commit is contained in:
Chop
2025-06-26 00:17:51 +02:00
parent 294d8343d3
commit 90875db28b
19 changed files with 785 additions and 147 deletions

View File

@@ -9,14 +9,22 @@ async function createNoteHandler(req) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
db.prepare(
try {
db.prepare(
`
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
`
INSERT INTO notes (project_id, task_id, note)
VALUES (?, ?, ?)
`
).run(project_id || null, task_id || null, note);
).run(project_id || null, task_id || null, note, req.user?.id || null);
return NextResponse.json({ success: true });
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error creating note:", error);
return NextResponse.json(
{ error: "Failed to create note", details: error.message },
{ status: 500 }
);
}
}
async function deleteNoteHandler(_, { params }) {

View File

@@ -17,11 +17,12 @@ async function updateProjectTaskHandler(req, { params }) {
);
}
updateProjectTaskStatus(params.id, status);
updateProjectTaskStatus(params.id, status, req.user?.id || null);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating task status:", error);
return NextResponse.json(
{ error: "Failed to update project task" },
{ error: "Failed to update project task", details: error.message },
{ status: 500 }
);
}

View File

@@ -43,11 +43,20 @@ async function createProjectTaskHandler(req) {
);
}
const result = createProjectTask(data);
// Add user tracking information from authenticated session
const taskData = {
...data,
created_by: req.user?.id || null,
// If no assigned_to is specified, default to the creator
assigned_to: data.assigned_to || req.user?.id || null,
};
const result = createProjectTask(taskData);
return NextResponse.json({ success: true, id: result.lastInsertRowid });
} catch (error) {
console.error("Error creating project task:", error);
return NextResponse.json(
{ error: "Failed to create project task" },
{ error: "Failed to create project task", details: error.message },
{ status: 500 }
);
}

View File

@@ -0,0 +1,50 @@
import {
updateProjectTaskAssignment,
getAllUsersForTaskAssignment,
} from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
// GET: Get all users for task assignment
async function getUsersForTaskAssignmentHandler(req) {
try {
const users = getAllUsersForTaskAssignment();
return NextResponse.json(users);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// POST: Update task assignment
async function updateTaskAssignmentHandler(req) {
try {
const { taskId, assignedToUserId } = await req.json();
if (!taskId) {
return NextResponse.json(
{ error: "taskId is required" },
{ status: 400 }
);
}
const result = updateProjectTaskAssignment(taskId, assignedToUserId);
if (result.changes === 0) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: "Failed to update task assignment" },
{ status: 500 }
);
}
}
// Protected routes
export const GET = withReadAuth(getUsersForTaskAssignmentHandler);
export const POST = withUserAuth(updateTaskAssignmentHandler);

View File

@@ -38,7 +38,7 @@ async function addTaskNoteHandler(req) {
);
}
addNoteToTask(task_id, note, is_system);
addNoteToTask(task_id, note, is_system, req.user?.id || null);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error adding task note:", error);

View File

@@ -400,12 +400,20 @@ export default async function ProjectViewPage({ params }) {
<div className="mb-8">
{" "}
<Card>
<CardHeader> <div className="flex items-center justify-between">
<CardHeader>
{" "}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
Project Location
</h2>
{project.coordinates && (
<Link href={`/projects/map?lat=${project.coordinates.split(',')[0].trim()}&lng=${project.coordinates.split(',')[1].trim()}&zoom=16`}>
<Link
href={`/projects/map?lat=${project.coordinates
.split(",")[0]
.trim()}&lng=${project.coordinates
.split(",")[1]
.trim()}&zoom=16`}
>
<Button variant="outline" size="sm">
<svg
className="w-4 h-4 mr-2"
@@ -481,9 +489,16 @@ export default async function ProjectViewPage({ params }) {
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-500">
{n.note_date}
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-500">
{n.note_date}
</span>
{n.created_by_name && (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
{n.created_by_name}
</span>
)}
</div>
</div>
<p className="text-gray-900 leading-relaxed">{n.note}</p>
</div>

View File

@@ -6,12 +6,14 @@ import Badge from "./ui/Badge";
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
const [taskTemplates, setTaskTemplates] = useState([]);
const [users, setUsers] = useState([]);
const [taskType, setTaskType] = useState("template"); // "template" or "custom"
const [selectedTemplate, setSelectedTemplate] = useState("");
const [customTaskName, setCustomTaskName] = useState("");
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
const [customDescription, setCustomDescription] = useState("");
const [priority, setPriority] = useState("normal");
const [assignedTo, setAssignedTo] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
@@ -19,6 +21,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
fetch("/api/tasks/templates")
.then((res) => res.json())
.then(setTaskTemplates);
// Fetch users for assignment
fetch("/api/project-tasks/users")
.then((res) => res.json())
.then(setUsers);
}, []);
async function handleSubmit(e) {
@@ -34,6 +41,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
const requestData = {
project_id: parseInt(projectId),
priority,
assigned_to: assignedTo || null,
};
if (taskType === "template") {
@@ -56,6 +64,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
setCustomMaxWaitDays("");
setCustomDescription("");
setPriority("normal");
setAssignedTo("");
if (onTaskAdded) onTaskAdded();
} else {
alert("Failed to add task to project.");
@@ -158,6 +167,24 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Assign To <span className="text-gray-500 text-xs">(optional)</span>
</label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Unassigned</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Priority

View File

@@ -273,6 +273,28 @@ export default function ProjectTasksList() {
<td className="px-4 py-3 text-sm text-gray-600">
{task.address || "N/A"}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{task.created_by_name ? (
<div>
<div className="font-medium">{task.created_by_name}</div>
<div className="text-xs text-gray-500">{task.created_by_email}</div>
</div>
) : (
"N/A"
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{task.assigned_to_name ? (
<div>
<div className="font-medium">{task.assigned_to_name}</div>
<div className="text-xs text-gray-500">
{task.assigned_to_email}
</div>
</div>
) : (
<span className="text-gray-400 italic">Unassigned</span>
)}
</td>
{showTimeLeft && (
<td className="px-4 py-3">
<div className="flex items-center gap-2">
@@ -361,7 +383,7 @@ export default function ProjectTasksList() {
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false }) => {
const filteredTasks = filterTasks(tasks);
const groupedTasks = groupTasksByName(filteredTasks);
const colSpan = showTimeLeft ? "8" : "7";
const colSpan = showTimeLeft ? "10" : "9";
return (
<div className="overflow-x-auto">
@@ -379,7 +401,13 @@ export default function ProjectTasksList() {
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Address
</th>{" "}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Created By
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Assigned To
</th>
{showTimeLeft && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Time Left

View File

@@ -517,6 +517,11 @@ export default function ProjectTasksSection({ projectId }) {
System
</span>
)}
{note.created_by_name && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full font-medium">
{note.created_by_name}
</span>
)}
</div>
<p className="text-sm text-gray-800">
{note.note}
@@ -525,6 +530,11 @@ export default function ProjectTasksSection({ projectId }) {
{formatDate(note.note_date, {
includeTime: true,
})}
{note.created_by_name && (
<span className="ml-2">
by {note.created_by_name}
</span>
)}
</p>
</div>
{!note.is_system && (

View File

@@ -196,11 +196,72 @@ export default function initializeDatabase() {
// Column already exists, ignore error
}
// Add foreign key indexes for performance
// Migration: Add user tracking columns to project_tasks table
try {
db.exec(`
CREATE INDEX IF NOT EXISTS idx_projects_created_by ON projects(created_by);
CREATE INDEX IF NOT EXISTS idx_projects_assigned_to ON projects(assigned_to);
ALTER TABLE project_tasks ADD COLUMN created_by TEXT;
`);
} catch (e) {
// Column already exists, ignore error
}
try {
db.exec(`
ALTER TABLE project_tasks ADD COLUMN assigned_to TEXT;
`);
} catch (e) {
// Column already exists, ignore error
}
try {
db.exec(`
ALTER TABLE project_tasks ADD COLUMN created_at TEXT;
`);
} catch (e) {
// Column already exists, ignore error
}
try {
db.exec(`
ALTER TABLE project_tasks ADD COLUMN updated_at TEXT;
`);
} catch (e) {
// Column already exists, ignore error
}
// Create indexes for project_tasks user tracking
try {
db.exec(`
CREATE INDEX IF NOT EXISTS idx_project_tasks_created_by ON project_tasks(created_by);
CREATE INDEX IF NOT EXISTS idx_project_tasks_assigned_to ON project_tasks(assigned_to);
`);
} catch (e) {
// Index already exists, ignore error
}
// Migration: Add user tracking columns to notes table
try {
db.exec(`
ALTER TABLE notes ADD COLUMN created_by TEXT;
`);
} catch (e) {
// Column already exists, ignore error
}
try {
db.exec(`
ALTER TABLE notes ADD COLUMN is_system INTEGER DEFAULT 0;
`);
} catch (e) {
// Column already exists, ignore error
}
// Create indexes for notes user tracking
try {
db.exec(`
CREATE INDEX IF NOT EXISTS idx_notes_created_by ON notes(created_by);
CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id);
CREATE INDEX IF NOT EXISTS idx_notes_task_id ON notes(task_id);
`);
} catch (e) {
// Index already exists, ignore error

View File

@@ -2,29 +2,100 @@ import db from "../db.js";
export function getNotesByProjectId(project_id) {
return db
.prepare(`SELECT * FROM notes WHERE project_id = ? ORDER BY note_date DESC`)
.prepare(
`
SELECT n.*,
u.name as created_by_name,
u.email as created_by_email
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
WHERE n.project_id = ?
ORDER BY n.note_date DESC
`
)
.all(project_id);
}
export function addNoteToProject(project_id, note) {
db.prepare(`INSERT INTO notes (project_id, note) VALUES (?, ?)`).run(
project_id,
note
);
export function addNoteToProject(project_id, note, created_by = null) {
db.prepare(
`
INSERT INTO notes (project_id, note, created_by, note_date)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`
).run(project_id, note, created_by);
}
export function getNotesByTaskId(task_id) {
return db
.prepare(`SELECT * FROM notes WHERE task_id = ? ORDER BY note_date DESC`)
.prepare(
`
SELECT n.*,
u.name as created_by_name,
u.email as created_by_email
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
WHERE n.task_id = ?
ORDER BY n.note_date DESC
`
)
.all(task_id);
}
export function addNoteToTask(task_id, note, is_system = false) {
export function addNoteToTask(
task_id,
note,
is_system = false,
created_by = null
) {
db.prepare(
`INSERT INTO notes (task_id, note, is_system) VALUES (?, ?, ?)`
).run(task_id, note, is_system ? 1 : 0);
`INSERT INTO notes (task_id, note, is_system, created_by, note_date)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`
).run(task_id, note, is_system ? 1 : 0, created_by);
}
export function deleteNote(note_id) {
db.prepare(`DELETE FROM notes WHERE note_id = ?`).run(note_id);
}
// Get all notes with user information (for admin/reporting purposes)
export function getAllNotesWithUsers() {
return db
.prepare(
`
SELECT n.*,
u.name as created_by_name,
u.email as created_by_email,
p.project_name,
COALESCE(pt.custom_task_name, t.name) as task_name
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
LEFT JOIN projects p ON n.project_id = p.project_id
LEFT JOIN project_tasks pt ON n.task_id = pt.id
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
ORDER BY n.note_date DESC
`
)
.all();
}
// Get notes created by a specific user
export function getNotesByCreator(userId) {
return db
.prepare(
`
SELECT n.*,
u.name as created_by_name,
u.email as created_by_email,
p.project_name,
COALESCE(pt.custom_task_name, t.name) as task_name
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
LEFT JOIN projects p ON n.project_id = p.project_id
LEFT JOIN project_tasks pt ON n.task_id = pt.id
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
WHERE n.created_by = ?
ORDER BY n.note_date DESC
`
)
.all(userId);
}

View File

@@ -222,9 +222,13 @@ export function getNotesForProject(projectId) {
return db
.prepare(
`
SELECT * FROM notes
WHERE project_id = ?
ORDER BY note_date DESC
SELECT n.*,
u.name as created_by_name,
u.email as created_by_email
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
WHERE n.project_id = ?
ORDER BY n.note_date DESC
`
)
.all(projectId);

View File

@@ -27,10 +27,16 @@ export function getAllProjectTasks() {
p.plot,
p.city,
p.address,
p.finish_date
p.finish_date,
creator.name as created_by_name,
creator.email as created_by_email,
assignee.name as assigned_to_name,
assignee.email as assigned_to_email
FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN projects p ON pt.project_id = p.project_id
LEFT JOIN users creator ON pt.created_by = creator.id
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
ORDER BY pt.date_added DESC
`
)
@@ -50,9 +56,15 @@ export function getProjectTasks(projectId) {
CASE
WHEN pt.task_template_id IS NOT NULL THEN 'template'
ELSE 'custom'
END as task_type
END as task_type,
creator.name as created_by_name,
creator.email as created_by_email,
assignee.name as assigned_to_name,
assignee.email as assigned_to_email
FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN users creator ON pt.created_by = creator.id
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
WHERE pt.project_id = ?
ORDER BY pt.date_added DESC
`
@@ -68,14 +80,19 @@ export function createProjectTask(data) {
if (data.task_template_id) {
// Creating from template - explicitly set custom_max_wait_days to NULL so COALESCE uses template value
const stmt = db.prepare(`
INSERT INTO project_tasks (project_id, task_template_id, custom_max_wait_days, status, priority)
VALUES (?, ?, NULL, ?, ?)
INSERT INTO project_tasks (
project_id, task_template_id, custom_max_wait_days, status, priority,
created_by, assigned_to, created_at, updated_at
)
VALUES (?, ?, NULL, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
result = stmt.run(
data.project_id,
data.task_template_id,
data.status || "pending",
data.priority || "normal"
data.priority || "normal",
data.created_by || null,
data.assigned_to || null
);
// Get the template name for the log
@@ -85,8 +102,11 @@ export function createProjectTask(data) {
} else {
// Creating custom task
const stmt = db.prepare(`
INSERT INTO project_tasks (project_id, custom_task_name, custom_max_wait_days, custom_description, status, priority)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO project_tasks (
project_id, custom_task_name, custom_max_wait_days, custom_description,
status, priority, created_by, assigned_to, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
result = stmt.run(
data.project_id,
@@ -94,7 +114,9 @@ export function createProjectTask(data) {
data.custom_max_wait_days || 0,
data.custom_description || "",
data.status || "pending",
data.priority || "normal"
data.priority || "normal",
data.created_by || null,
data.assigned_to || null
);
taskName = data.custom_task_name;
@@ -105,14 +127,14 @@ export function createProjectTask(data) {
const priority = data.priority || "normal";
const status = data.status || "pending";
const logMessage = `Task "${taskName}" created with priority: ${priority}, status: ${status}`;
addNoteToTask(result.lastInsertRowid, logMessage, true);
addNoteToTask(result.lastInsertRowid, logMessage, true, data.created_by);
}
return result;
}
// Update project task status
export function updateProjectTaskStatus(taskId, status) {
export function updateProjectTaskStatus(taskId, status, userId = null) {
// First get the current task details for logging
const getCurrentTask = db.prepare(`
SELECT
@@ -136,7 +158,7 @@ export function updateProjectTaskStatus(taskId, status) {
// Starting a task - set date_started
stmt = db.prepare(`
UPDATE project_tasks
SET status = ?, date_started = CURRENT_TIMESTAMP
SET status = ?, date_started = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
result = stmt.run(status, taskId);
@@ -144,7 +166,7 @@ export function updateProjectTaskStatus(taskId, status) {
// Completing a task - set date_completed
stmt = db.prepare(`
UPDATE project_tasks
SET status = ?, date_completed = CURRENT_TIMESTAMP
SET status = ?, date_completed = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
result = stmt.run(status, taskId);
@@ -152,7 +174,7 @@ export function updateProjectTaskStatus(taskId, status) {
// Just updating status without changing timestamps
stmt = db.prepare(`
UPDATE project_tasks
SET status = ?
SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
result = stmt.run(status, taskId);
@@ -162,7 +184,7 @@ export function updateProjectTaskStatus(taskId, status) {
if (result.changes > 0 && oldStatus !== status) {
const taskName = currentTask.task_name || "Unknown task";
const logMessage = `Status changed from "${oldStatus}" to "${status}"`;
addNoteToTask(taskId, logMessage, true);
addNoteToTask(taskId, logMessage, true, userId);
}
return result;
@@ -173,3 +195,99 @@ export function deleteProjectTask(taskId) {
const stmt = db.prepare("DELETE FROM project_tasks WHERE id = ?");
return stmt.run(taskId);
}
// Get project tasks assigned to a specific user
export function getProjectTasksByAssignedUser(userId) {
return db
.prepare(
`
SELECT
pt.*,
COALESCE(pt.custom_task_name, t.name) as task_name,
COALESCE(pt.custom_max_wait_days, t.max_wait_days) as max_wait_days,
COALESCE(pt.custom_description, t.description) as description,
CASE
WHEN pt.task_template_id IS NOT NULL THEN 'template'
ELSE 'custom'
END as task_type,
p.project_name,
p.wp,
p.plot,
p.city,
p.address,
p.finish_date,
creator.name as created_by_name,
creator.email as created_by_email,
assignee.name as assigned_to_name,
assignee.email as assigned_to_email
FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN projects p ON pt.project_id = p.project_id
LEFT JOIN users creator ON pt.created_by = creator.id
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
WHERE pt.assigned_to = ?
ORDER BY pt.date_added DESC
`
)
.all(userId);
}
// Get project tasks created by a specific user
export function getProjectTasksByCreator(userId) {
return db
.prepare(
`
SELECT
pt.*,
COALESCE(pt.custom_task_name, t.name) as task_name,
COALESCE(pt.custom_max_wait_days, t.max_wait_days) as max_wait_days,
COALESCE(pt.custom_description, t.description) as description,
CASE
WHEN pt.task_template_id IS NOT NULL THEN 'template'
ELSE 'custom'
END as task_type,
p.project_name,
p.wp,
p.plot,
p.city,
p.address,
p.finish_date,
creator.name as created_by_name,
creator.email as created_by_email,
assignee.name as assigned_to_name,
assignee.email as assigned_to_email
FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN projects p ON pt.project_id = p.project_id
LEFT JOIN users creator ON pt.created_by = creator.id
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
WHERE pt.created_by = ?
ORDER BY pt.date_added DESC
`
)
.all(userId);
}
// Update project task assignment
export function updateProjectTaskAssignment(taskId, assignedToUserId) {
const stmt = db.prepare(`
UPDATE project_tasks
SET assigned_to = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
return stmt.run(assignedToUserId, taskId);
}
// Get active users for task assignment (same as projects)
export function getAllUsersForTaskAssignment() {
return db
.prepare(
`
SELECT id, name, email, role
FROM users
WHERE is_active = 1
ORDER BY name ASC
`
)
.all();
}