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:
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user