Add tasks: the crew's shared to-do list

Bots and workspaces each keep a list of work tracked until it's done, and
the bots move it along themselves as they work.

- tasks (one collection per bot or per workspace), task_notes (the
  progress log) and task_media (a deliverable or a reference file, its
  bytes a private media row served at /media/private/<id>)
- statuses todo/doing/blocked/done/cancelled, with blocked_reason while
  only the user can unblock it
- visibility as SQL in src/utils/task-access: a user's own bots and
  workspaces, a run's own list plus its workspaces, everything for the
  coordinator; every tool and endpoint resolves through it
- list_tasks, create_task and update_task on every run, so a chat reply,
  a task and a routine all read the list, log progress and file what they
  produced
- TasksNote on every run and TaskReminderNote in chat, so a greeting or a
  proposed next step brings pending work up in one measured line
- tasks group on the workspace dashboard and a tasks modal in the
  conversation header, sharing one form, row and detail panel
- a bot's or workspace's own chat uploads are read before the delete so
  none are stranded; bot files keep their folder

Also corrects the README's RAM guidance and table alignment.
This commit is contained in:
2026-10-22 05:57:50 +01:00
parent 2d108b05bf
commit 7cb26c4e36
60 changed files with 3793 additions and 52 deletions
+41
View File
@@ -0,0 +1,41 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
import { TaskNoteRequestSchema } from "@/src/schemas/task-schema";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Adds a note to a task's progress log and returns the task with it.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { task_id, text } = parseWithSchema({ schema: TaskNoteRequestSchema, data: body });
await assertUserReachesTask({ user_id: user.id, task_id });
await addTaskNote({ task_id, text, author: "user" });
return { success: true, singleRes: await grabTask({ user_id: user.id, task_id }), msg: `Note added.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+103
View File
@@ -0,0 +1,103 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
import { AppData } from "@/src/data/app-data";
import { TaskFileRequestSchema, UploadTaskFileRequestSchema } from "@/src/schemas/task-schema";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import attachmentFileKind from "@/src/utils/attachment-file-kind";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Links a file to a task: one of the user's own uploads (`media_id`), or a
* new upload (`data_url` + `name`), saved as a private `media` row with
* the `task-media` paradigm. Returns the task with its files.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const data = (body || {}) as Record<string, unknown>;
if (data.data_url) {
const { task_id, data_url, name, kind } = parseWithSchema({
schema: UploadTaskFileRequestSchema,
data,
});
await assertUserReachesTask({ user_id: user.id, task_id });
const mime = data_url.match(/^data:([^;,]*)/)?.[1] || "";
const { media_type, ext } = attachmentFileKind({ mime, name });
const { buffer } = await bufferFromBase64(data_url);
if (!buffer.length) {
throw new AppError(`${name} is empty.`);
}
if (buffer.length > AppData.BotAttachmentMaxBytes) {
throw new AppError(
`${name} is too big. Files can be up to ${Math.round(AppData.BotAttachmentMaxBytes / 1024 / 1024)} MB.`,
);
}
const saved = await uploadAndRecordMedia({
data: buffer,
media_type,
media_mime_type: ext,
media_paradigms: ["task-media"],
media: { title: name },
extract_media_text_content: media_type == "document",
is_private: true,
user: { id: Number(user.id) },
});
if (!saved.singleRes?.id) {
throw new AppError(`Couldn't save ${name}.`);
}
await attachTaskFile({
task_id,
media_id: saved.singleRes.id,
user_id: user.id,
kind: kind as never,
});
return { success: true, singleRes: await grabTask({ user_id: user.id, task_id }), msg: `File linked.` };
}
const { task_id, media_id, file_id, kind } = parseWithSchema({ schema: TaskFileRequestSchema, data });
await assertUserReachesTask({ user_id: user.id, task_id });
await attachTaskFile({
task_id,
media_id,
file_id,
user_id: user.id,
kind: kind as never,
});
return { success: true, singleRes: await grabTask({ user_id: user.id, task_id }), msg: `File linked.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+40
View File
@@ -0,0 +1,40 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import deleteTask from "@/src/functions/backend/db/tasks/delete-task";
import { DeleteTaskRequestSchema } from "@/src/schemas/task-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Deletes one of the user's tasks with its notes and file links. The
* files themselves stay where they live.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { task_id } = parseWithSchema({ schema: DeleteTaskRequestSchema, data: body });
await assertUserReachesTask({ user_id: user.id, task_id });
await deleteTask({ user_id: user.id, task_id });
return { success: true, msg: `Task deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+44
View File
@@ -0,0 +1,44 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import detachTaskFile from "@/src/functions/backend/db/tasks/detach-task-file";
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
import { DetachTaskFileRequestSchema } from "@/src/schemas/task-schema";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Unlinks one file from a task and returns it with the rest.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { task_id, task_media_id, media_id } = parseWithSchema({
schema: DetachTaskFileRequestSchema,
data: body,
});
await assertUserReachesTask({ user_id: user.id, task_id });
await detachTaskFile({ task_id, task_media_id, media_id, user_id: user.id });
return { success: true, singleRes: await grabTask({ user_id: user.id, task_id }), msg: `File unlinked.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+36
View File
@@ -0,0 +1,36 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
import type { TaskDetail } from "@/src/types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* One task with its notes and files: what a row's modal loads when it
* opens, so the page props stay small.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req }) => {
try {
if (req.method != "GET") {
return { success: false, msg: `Method not allowed` };
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const task_id = Number(new URL(req.url).searchParams.get("task_id"));
if (!task_id) {
return { success: false, msg: `No task given.` };
}
return {
success: true,
singleRes: await grabTask({ user_id: user.id, task_id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+43
View File
@@ -0,0 +1,43 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabTasks from "@/src/functions/backend/db/tasks/grab-tasks";
import type { TaskWithNames } from "@/src/types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's shared one
* (`?workspace_id=`), or every task they reach. `?status=` narrows to one
* status, `?open=1` to the open ones.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => {
try {
if (req.method != "GET") {
return { success: false, msg: `Method not allowed` };
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const url = new URL(req.url);
const bot_id = Number(url.searchParams.get("bot_id")) || undefined;
const workspace_id = Number(url.searchParams.get("workspace_id")) || undefined;
const status = url.searchParams.get("status") || undefined;
const open = url.searchParams.get("open") == "1";
return {
success: true,
payload: await grabTasks({
user_id: user.id,
only_bot_id: bot_id,
only_workspace_id: workspace_id,
statuses: status ? [status] : undefined,
open_only: open,
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+78
View File
@@ -0,0 +1,78 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import createTask from "@/src/functions/backend/db/tasks/create-task";
import updateTask from "@/src/functions/backend/db/tasks/update-task";
import { CreateTaskRequestSchema, UpdateTaskRequestSchema } from "@/src/schemas/task-schema";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Adds a task to a bot's list or a workspace's (`bot_id` or
* `workspace_id`), or changes one (`task_id`). Returns the task with its
* notes and files.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const data = (body || {}) as Record<string, unknown>;
if (data.task_id) {
const { task_id, title, description, status, blocked_reason } = parseWithSchema({
schema: UpdateTaskRequestSchema,
data,
});
await assertUserReachesTask({ user_id: user.id, task_id });
return {
success: true,
singleRes: await updateTask({
user_id: user.id,
task_id,
title,
description,
status: status as never,
blocked_reason,
}),
msg: `Saved.`,
};
}
const { bot_id, workspace_id, title, description, status } = parseWithSchema({
schema: CreateTaskRequestSchema,
data,
});
return {
success: true,
singleRes: await createTask({
user_id: user.id,
bot_id,
workspace_id,
title,
description,
status: status as never,
}),
msg: `Task added.`,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};