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
@@ -0,0 +1,55 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useEffect, useState } from "react";
import type { TaskWithNames } from "@/src/types";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Load only once the modal opens, so the conversation's page props stay
* small.
*/
enabled?: boolean;
};
/**
* A conversation's tasks: a bot's own list, or in a workspace the
* workspace's shared one. Loaded on open and refreshed after every change,
* never part of the page props.
*/
export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Params) {
const [tasks, setTasks] = useState<TaskWithNames[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
const query = new URLSearchParams();
if (workspace_id) query.set("workspace_id", String(workspace_id));
else query.set("bot_id", String(bot_id));
const res = await fetchApi<{}, APIResponseObject<TaskWithNames>>(
`/api/admin/tasks/list-tasks?${query.toString()}`,
{ method: "GET" },
).catch(() => null);
setLoading(false);
if (!res?.success) {
setError(res?.msg || `Couldn't load the tasks.`);
return;
}
setTasks(res.payload || []);
}, [bot_id, workspace_id]);
useEffect(() => {
if (enabled) load().catch(() => {});
}, [enabled, load]);
return { tasks, setTasks, loading, error, reload: load };
}
@@ -0,0 +1,229 @@
import { useContext, useEffect, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { ListChecks } from "lucide-react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types";
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
import TaskForm from "@/src/components/general/task-form";
import TaskListItem from "@/src/components/general/task-list-item";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import useBotTasks from "../(hooks)/use-bot-tasks";
import HeaderMenuItem from "./header-menu-item";
type Props = {
bot: BotWithLastMessage;
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
/**
* A row in the conversation header's menu instead of an icon beside it.
*/
menu?: boolean;
on_open?: () => void;
};
/**
* The tasks this conversation works on: a bot's own list, or in a workspace
* the workspace's shared one. Open it from the header menu; the list loads
* then, so the page's props stay small. A crew manager adds, moves, edits
* and deletes; a task opens to show its notes and files.
*/
export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
const [adding, set_adding] = useState(false);
const [open_task, set_open_task] = useState<TaskDetail | null>(null);
const [busy, set_busy] = useState(false);
const [status, set_status] = useState<string>();
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const { tasks, setTasks, loading, error, reload } = useBotTasks({
bot_id: Number(bot.id),
workspace_id: workspace?.id ? Number(workspace.id) : null,
enabled: open,
});
const name = bot.name || "This bot";
const title = workspace?.id ? `${workspace.name || "Workspace"} tasks` : `${name}'s tasks`;
useEffect(() => {
if (open) on_open?.();
}, [open]);
const replace = ({ task }: { task: TaskWithNames }) =>
setTasks((prev) => [task, ...prev.filter((item) => item.id != task.id)]);
const move = async ({ task, next }: { task: TaskWithNames; next: string }) => {
if (busy) return;
set_busy(true);
set_status(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
"/api/admin/tasks/save-task",
{ method: "POST", body: { task_id: Number(task.id), status: next } },
);
set_busy(false);
if (!res.success || !res.singleRes) {
set_status(res.msg || "That didn't work.");
return;
}
replace({ task: res.singleRes });
};
const show = async ({ task }: { task: TaskWithNames }) => {
set_status(undefined);
/**
* The list already has the task; the detail endpoint serves its
* notes and files.
*/
const detail = await fetchApi<{}, APIResponseObject<TaskDetail>>(
`/api/admin/tasks/get-task?task_id=${task.id}`,
{ method: "GET" },
).catch(() => null);
if (!detail?.success || !detail.singleRes) {
set_status(detail?.msg || "Couldn't open the task.");
return;
}
set_open_task(detail.singleRes);
};
return (
<Modal
target={
menu ? (
<HeaderMenuItem
icon={ListChecks}
title="Tasks"
description={title}
loading={loading}
/>
) : (
<Button
title={title}
type="button"
variant="ghost"
color="text"
className="shrink-0 p-2! text-ink-secondary hover:text-ink hover:bg-sunken rounded-default"
>
<ListChecks size={18} aria-hidden />
</Button>
)
}
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
open={open}
setOpen={(next) => {
setOpen(next);
if (!next) {
set_open_task(null);
set_adding(false);
}
}}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
{open_task ? open_task.title || "Task" : title}
</H2>
{open_task ? (
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => set_open_task(null)}
className="px-2! text-[12px]"
>
Back
</Button>
) : can_manage ? (
<Button
title="Add a task"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => {
set_adding(true);
set_open_task(null);
}}
>
Add task
</Button>
) : null}
</Row>
{!open_task ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{workspace?.id
? `The shared to-do list of ${workspace.name || "this workspace"}. Every bot here sees it, and they keep it up to date as they work.`
: `What ${name} is working through, and what it's waiting on.`}
</Span>
) : null}
</Stack>
<Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]">
{open_task ? (
<TaskDetailPanel
task={open_task}
can_manage={can_manage}
onChanged={(saved, msg) => {
set_open_task(saved);
replace({ task: saved });
set_status(msg);
}}
onDeleted={() => {
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
set_open_task(null);
set_status("Task deleted.");
}}
/>
) : adding ? (
<TaskForm
bot_id={workspace?.id ? null : Number(bot.id)}
workspace_id={workspace?.id ? Number(workspace.id) : null}
onCancel={() => set_adding(false)}
onSaved={(saved, msg) => {
replace({ task: saved });
set_adding(false);
set_status(msg);
}}
/>
) : tasks.length ? (
tasks.map((task) => (
<TaskListItem
key={task.id}
task={task}
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })}
/>
))
) : (
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
{loading ? "Loading tasks..." : error || "No tasks yet."}
</Span>
)}
</Stack>
{status ? (
<Span role="status" className="mt-3 text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</Modal>
);
}
@@ -11,6 +11,7 @@ import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotComputersModal from "./bot-computers-modal";
import BotFilesModal from "./bot-files-modal";
import BotTasksModal from "./bot-tasks-modal";
import BotModelsModal from "./bot-models-modal";
import HeaderMenuItem from "./header-menu-item";
import NewSessionButton from "./new-session-button";
@@ -101,6 +102,12 @@ export default function ConversationHeaderMenu({
workspace={workspace}
on_open={on_open}
/>
<BotTasksModal
menu
bot={bot}
workspace={workspace}
on_open={on_open}
/>
<BotComputersModal
menu
bot={bot}
@@ -0,0 +1,182 @@
import { useContext, useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import type { TaskDetail, TaskWithNames } from "@/src/types";
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
import TaskForm from "@/src/components/general/task-form";
import TaskListItem from "@/src/components/general/task-list-item";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
type Props = {
workspace_id: number;
/**
* Its tasks, open first.
*/
tasks: TaskWithNames[];
};
/**
* A workspace's shared to-do list: every bot working there sees these, and
* the coordinator sees them all. Add and change one by hand here; the bots
* keep it up to date themselves as they work.
*/
export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
const { pageProps } = useContext(AppContext);
const [items, setItems] = useState(tasks);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg: string }>();
const [adding, setAdding] = useState(false);
const [open_task, setOpenTask] = useState<TaskDetail | null>(null);
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const replace = ({ task }: { task: TaskWithNames }) =>
setItems((prev) => [task, ...prev.filter((item) => item.id != task.id)].sort(order));
const move = async ({ task, next }: { task: TaskWithNames; next: string }) => {
if (busy) return;
setBusy(true);
setStatus(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
"/api/admin/tasks/save-task",
{ method: "POST", body: { task_id: Number(task.id), status: next } },
);
setBusy(false);
if (!res.success || !res.singleRes) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
replace({ task: res.singleRes });
};
const show = async ({ task }: { task: TaskWithNames }) => {
setStatus(undefined);
const res = await fetchApi<{}, APIResponseObject<TaskDetail>>(
`/api/admin/tasks/get-task?task_id=${task.id}`,
{ method: "GET" },
).catch(() => null);
if (!res?.success || !res.singleRes) {
setStatus({ error: true, msg: res?.msg || "Couldn't open the task." });
return;
}
setOpenTask(res.singleRes);
};
return (
<SettingsGroup
id="tasks"
title="Tasks"
description="The workspace's shared to-do list. Every bot here sees it, and they keep it up to date as they work."
>
<Stack className="w-full gap-2! items-stretch">
{open_task ? (
<Stack className="w-full gap-3! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="grow min-w-0 text-sm font-bold leading-5 text-ink truncate">
{open_task.title || "Task"}
</Span>
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setOpenTask(null)}
className="px-2! text-[12px]"
>
Back
</Button>
</Row>
<TaskDetailPanel
task={open_task}
can_manage={can_manage}
onChanged={(saved, msg) => {
setOpenTask(saved);
replace({ task: saved });
setStatus(msg ? { success: true, msg } : undefined);
}}
onDeleted={() => {
setItems((prev) => prev.filter((item) => item.id != open_task.id));
setOpenTask(null);
setStatus({ success: true, msg: "Task deleted." });
}}
/>
</Stack>
) : (
<>
{can_manage ? (
<Row className="justify-end">
<Button
title="Add a task"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => setAdding(true)}
>
Add task
</Button>
</Row>
) : null}
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
{items.map((task) => (
<TaskListItem
key={task.id}
task={task}
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })}
/>
))}
</SettingsList>
</>
)}
<SettingsStatus status={status} />
</Stack>
<AddModalShell
open={adding}
setOpen={setAdding}
title="Add a task"
description="It goes on the workspace's shared list, where every bot here can pick it up."
>
<TaskForm
workspace_id={workspace_id}
onCancel={() => setAdding(false)}
onSaved={(task, msg) => {
replace({ task });
setAdding(false);
setStatus({ success: true, msg });
}}
/>
</AddModalShell>
</SettingsGroup>
);
}
/**
* Blocked, then doing, then to do, then finished: the order a list reads
* in.
*/
function order(a: TaskWithNames, b: TaskWithNames) {
const rank = (task: TaskWithNames) =>
task.status == "blocked" ? 0 : task.status == "doing" ? 1 : task.status == "todo" ? 2 : 3;
const diff = rank(a) - rank(b);
return diff != 0 ? diff : Number(b.updated_at || b.id || 0) - Number(a.updated_at || a.id || 0);
}
@@ -8,6 +8,7 @@ import WorkspaceAboutGroup from "../(partials)/workspace-about-group";
import WorkspaceActivityGroup from "../(partials)/workspace-activity-group";
import WorkspaceBotsListGroup from "../(partials)/workspace-bots-list-group";
import WorkspaceComputersListGroup from "../(partials)/workspace-computers-list-group";
import WorkspaceTasksGroup from "../(partials)/workspace-tasks-group";
/**
* A workspace's dashboard: a read-only summary of its picture and context,
@@ -38,6 +39,10 @@ export default function WorkspaceDashboard() {
}
>
<WorkspaceAboutGroup workspace={workspace} />
<WorkspaceTasksGroup
workspace_id={workspace_id}
tasks={pageProps?.workspace_tasks || []}
/>
<WorkspaceActivityGroup
workspace_id={workspace_id}
activity={pageProps?.workspace_activity || []}
@@ -3,6 +3,7 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-workspace-activity";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import grabTasks from "@/src/functions/backend/db/tasks/grab-tasks";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
@@ -33,11 +34,12 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const workspace_id = Number(workspace.id);
const [workspace_bots, workspace_computers, workspace_activity] =
const [workspace_bots, workspace_computers, workspace_activity, workspace_tasks] =
await Promise.all([
grabWorkspaceBots({ user_id: user.id, workspace_id }),
grabWorkspaceComputers({ workspace_id }),
grabWorkspaceActivity({ user_id: user.id, workspace_id }),
grabTasks({ user_id: user.id, only_workspace_id: workspace_id }),
]);
const crew = railBots({ bots: workspace_bots });
@@ -50,6 +52,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
workspace_bots: crew,
workspace_computers,
workspace_activity,
workspace_tasks,
},
};
};
+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 }) };
}
};