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:
2026-03-29 13:49:25 +00:00
parent fc39029ce3
commit b4b8439f80
14 changed files with 1173 additions and 136 deletions

View File

@@ -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
View 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;

View File

@@ -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;