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