Feature batch: Projects, Recurrence, Group settings, Bug fixes
- Projects CRUD API + invite members - Task recurrence (daily/weekly/monthly) with auto-creation - Group time zones + GPS locations settings - i18n fallback fix (no more undefined labels) - UX: action buttons in one row - Chat/Calendar: relative API URLs - DB: task_assignments, projects tables, recurrence column Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,7 @@ const start = async () => {
|
||||
await app.register(require("./routes/chat"), { prefix: "/api/v1" });
|
||||
await app.register(require("./routes/notifications"), { prefix: "/api/v1" });
|
||||
await app.register(require("./routes/goals"), { prefix: "/api/v1" });
|
||||
await app.register(require("./routes/projects"), { prefix: "/api/v1" });
|
||||
await app.register(require("./routes/deploy"), { prefix: "/api/v1" });
|
||||
await app.register(require("./routes/system"), { prefix: "/api/v1" });
|
||||
|
||||
|
||||
@@ -57,6 +57,50 @@ async function groupRoutes(app) {
|
||||
}
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
// Update time zones for a group
|
||||
app.put('/groups/:id/timezones', async (req) => {
|
||||
const { time_zones } = req.body;
|
||||
if (!Array.isArray(time_zones)) {
|
||||
throw { statusCode: 400, message: 'time_zones must be an array of [{days, from, to}]' };
|
||||
}
|
||||
// Validate each timezone entry
|
||||
for (const tz of time_zones) {
|
||||
if (!Array.isArray(tz.days) || !tz.from || !tz.to) {
|
||||
throw { statusCode: 400, message: 'Each timezone must have days (array), from (HH:MM), to (HH:MM)' };
|
||||
}
|
||||
}
|
||||
const { rows } = await app.db.query(
|
||||
'UPDATE task_groups SET time_zones = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
||||
[JSON.stringify(time_zones), req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
// Update GPS locations for a group
|
||||
app.put('/groups/:id/locations', async (req) => {
|
||||
const { locations } = req.body;
|
||||
if (!Array.isArray(locations)) {
|
||||
throw { statusCode: 400, message: 'locations must be an array of [{name, lat, lng, radius_m}]' };
|
||||
}
|
||||
// Validate each location entry
|
||||
for (const loc of locations) {
|
||||
if (!loc.name || loc.lat === undefined || loc.lng === undefined) {
|
||||
throw { statusCode: 400, message: 'Each location must have name, lat, lng' };
|
||||
}
|
||||
if (typeof loc.lat !== 'number' || typeof loc.lng !== 'number') {
|
||||
throw { statusCode: 400, message: 'lat and lng must be numbers' };
|
||||
}
|
||||
}
|
||||
const { rows } = await app.db.query(
|
||||
'UPDATE task_groups SET locations = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
||||
[JSON.stringify(locations), req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
module.exports = groupRoutes;
|
||||
|
||||
114
api/src/routes/projects.js
Normal file
114
api/src/routes/projects.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// Task Team — Projects CRUD — 2026-03-29
|
||||
async function projectRoutes(app) {
|
||||
|
||||
// List projects (optionally filter by member)
|
||||
app.get("/projects", async (req) => {
|
||||
const { user_id } = req.query;
|
||||
let query = "SELECT * FROM projects";
|
||||
const params = [];
|
||||
if (user_id) {
|
||||
params.push(user_id);
|
||||
query += ` WHERE owner_id = $1 OR $1 = ANY(members)`;
|
||||
}
|
||||
query += " ORDER BY updated_at DESC";
|
||||
const { rows } = await app.db.query(query, params);
|
||||
return { data: rows };
|
||||
});
|
||||
|
||||
// Get single project with task count
|
||||
app.get("/projects/:id", async (req) => {
|
||||
const { rows } = await app.db.query(
|
||||
`SELECT p.*,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) as task_count,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id AND t.status IN ('done','completed')) as done_count
|
||||
FROM projects p WHERE p.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: "Project not found" };
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
// Create project
|
||||
app.post("/projects", async (req) => {
|
||||
const { name, description, color, icon, owner_id } = req.body;
|
||||
if (!name || !name.trim()) throw { statusCode: 400, message: "name is required" };
|
||||
const { rows } = await app.db.query(
|
||||
`INSERT INTO projects (name, description, color, icon, owner_id, members)
|
||||
VALUES ($1, $2, $3, $4, $5, ARRAY[$5]::uuid[]) RETURNING *`,
|
||||
[name.trim(), description || "", color || "#3B82F6", icon || "\ud83d\udcc1", owner_id]
|
||||
);
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
// Update project
|
||||
app.put("/projects/:id", async (req) => {
|
||||
const { name, description, color, icon } = req.body;
|
||||
const { rows } = await app.db.query(
|
||||
`UPDATE projects SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
color = COALESCE($3, color),
|
||||
icon = COALESCE($4, icon),
|
||||
updated_at = NOW()
|
||||
WHERE id = $5 RETURNING *`,
|
||||
[name, description, color, icon, req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: "Project not found" };
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
// Invite user to project
|
||||
app.post("/projects/:id/invite", async (req) => {
|
||||
const { user_id } = req.body;
|
||||
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
|
||||
const { rows } = await app.db.query(
|
||||
`UPDATE projects SET
|
||||
members = array_append(members, $1::uuid),
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND NOT ($1::uuid = ANY(members))
|
||||
RETURNING *`,
|
||||
[user_id, req.params.id]
|
||||
);
|
||||
if (!rows.length) {
|
||||
// Check if project exists
|
||||
const check = await app.db.query("SELECT id FROM projects WHERE id = $1", [req.params.id]);
|
||||
if (!check.rows.length) throw { statusCode: 404, message: "Project not found" };
|
||||
return { status: "already_member" };
|
||||
}
|
||||
return { data: rows[0], status: "invited" };
|
||||
});
|
||||
|
||||
// Remove member from project
|
||||
app.delete("/projects/:id/members/:userId", async (req) => {
|
||||
const { rows } = await app.db.query(
|
||||
`UPDATE projects SET
|
||||
members = array_remove(members, $1::uuid),
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 RETURNING *`,
|
||||
[req.params.userId, req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: "Project not found" };
|
||||
return { data: rows[0], status: "removed" };
|
||||
});
|
||||
|
||||
// Delete project (sets tasks.project_id to NULL via ON DELETE SET NULL)
|
||||
app.delete("/projects/:id", async (req) => {
|
||||
const { rowCount } = await app.db.query("DELETE FROM projects WHERE id = $1", [req.params.id]);
|
||||
if (!rowCount) throw { statusCode: 404, message: "Project not found" };
|
||||
return { status: "deleted" };
|
||||
});
|
||||
|
||||
// List tasks in project
|
||||
app.get("/projects/:id/tasks", async (req) => {
|
||||
const { rows } = await app.db.query(
|
||||
`SELECT t.*, tg.name as group_name, tg.color as group_color, tg.icon as group_icon
|
||||
FROM tasks t LEFT JOIN task_groups tg ON t.group_id = tg.id
|
||||
WHERE t.project_id = $1
|
||||
ORDER BY t.priority DESC, t.created_at DESC`,
|
||||
[req.params.id]
|
||||
);
|
||||
return { data: rows };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = projectRoutes;
|
||||
@@ -183,6 +183,45 @@ async function taskRoutes(app) {
|
||||
`UPDATE tasks SET ${sets.join(", ")} WHERE id = $${i} RETURNING *`, params
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
|
||||
|
||||
// Auto-create next occurrence for recurring tasks
|
||||
const updatedTask = rows[0];
|
||||
if ((fields.status === "completed" || fields.status === "done") && updatedTask.recurrence) {
|
||||
try {
|
||||
const rec = typeof updatedTask.recurrence === "string" ? JSON.parse(updatedTask.recurrence) : updatedTask.recurrence;
|
||||
let nextDate = new Date();
|
||||
if (rec.type === "daily") {
|
||||
nextDate.setDate(nextDate.getDate() + 1);
|
||||
} else if (rec.type === "weekly") {
|
||||
nextDate.setDate(nextDate.getDate() + 7);
|
||||
} else if (rec.type === "monthly") {
|
||||
nextDate.setMonth(nextDate.getMonth() + 1);
|
||||
}
|
||||
if (rec.time) {
|
||||
const [h, m] = rec.time.split(":");
|
||||
nextDate.setHours(parseInt(h) || 8, parseInt(m) || 0, 0, 0);
|
||||
}
|
||||
await app.db.query(
|
||||
`INSERT INTO tasks (title, description, status, group_id, priority, scheduled_at, due_at, assigned_to, recurrence, project_id)
|
||||
VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
updatedTask.title,
|
||||
updatedTask.description,
|
||||
updatedTask.group_id,
|
||||
updatedTask.priority,
|
||||
nextDate.toISOString(),
|
||||
updatedTask.due_at ? new Date(new Date(updatedTask.due_at).getTime() + (nextDate.getTime() - Date.now())).toISOString() : null,
|
||||
updatedTask.assigned_to || [],
|
||||
JSON.stringify(rec),
|
||||
updatedTask.project_id
|
||||
]
|
||||
);
|
||||
app.log.info("Auto-created next recurring task for: " + updatedTask.title);
|
||||
} catch (recErr) {
|
||||
app.log.warn("Recurrence auto-create failed: " + recErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
await invalidateTaskCaches();
|
||||
return { data: rows[0] };
|
||||
});
|
||||
@@ -267,6 +306,47 @@ async function taskRoutes(app) {
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
|
||||
// === Task Recurrence Endpoints ===
|
||||
|
||||
// Set recurrence on a task
|
||||
app.post("/tasks/:id/recurrence", async (req) => {
|
||||
const { type, days, time } = req.body;
|
||||
if (!type || !["daily", "weekly", "monthly"].includes(type)) {
|
||||
throw { statusCode: 400, message: "type must be one of: daily, weekly, monthly" };
|
||||
}
|
||||
const recurrence = { type, days: days || [], time: time || "08:00" };
|
||||
const { rows } = await app.db.query(
|
||||
"UPDATE tasks SET recurrence = $1, updated_at = NOW() WHERE id = $2 RETURNING *",
|
||||
[JSON.stringify(recurrence), req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
|
||||
await invalidateTaskCaches();
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
// Remove recurrence from a task
|
||||
app.delete("/tasks/:id/recurrence", async (req) => {
|
||||
const { rows } = await app.db.query(
|
||||
"UPDATE tasks SET recurrence = NULL, updated_at = NOW() WHERE id = $1 RETURNING *",
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
|
||||
await invalidateTaskCaches();
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
// List recurring tasks
|
||||
app.get("/tasks/recurring", async (req) => {
|
||||
const { rows } = await app.db.query(
|
||||
`SELECT t.*, tg.name as group_name, tg.color as group_color, tg.icon as group_icon
|
||||
FROM tasks t LEFT JOIN task_groups tg ON t.group_id = tg.id
|
||||
WHERE t.recurrence IS NOT NULL
|
||||
ORDER BY t.created_at DESC`
|
||||
);
|
||||
return { data: rows };
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
module.exports = taskRoutes;
|
||||
|
||||
Reference in New Issue
Block a user