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
+12 -1
View File
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
import AppError from "@/src/utils/app-error";
import grabUserBot from "./grab-user-bot";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
@@ -29,11 +30,13 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
}
/**
* Read before the delete: the files' rows go with the bot.
* Read before the delete: the files' rows go with the bot, and so do its
* tasks and the links to what they filed.
*/
const file_media_ids = await grabStorageMediaIds({ scope: "bot", owner_id: bot.id! }).catch(
() => [],
);
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
@@ -45,6 +48,14 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
}
/**
* What the bot's tasks filed and no folder owns (a chat upload): the
* links went with the tasks, so these bytes would be stranded.
*/
await deleteMedia({ ids: task_media_ids, user_id: Number(user_id), can_delete_all_media: true }).catch(
() => {},
);
/**
* Its folder goes with it: the bytes, their `media` rows and the search
* vectors.
@@ -0,0 +1,59 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_NOTES, BunSQLiteTables } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { TaskAuthor } from "@/src/dict/task-dict";
import AppError from "@/src/utils/app-error";
import grabTaskRow from "./grab-task-scope";
type Params = {
task_id: number | string;
text: string;
author?: TaskAuthor;
/**
* The bot writing it, when a bot does.
*/
bot_id?: number | string | null;
};
/**
* Adds a line to a task's progress log. The caller checked the run or user
* reaches the task. Returns the note read back, so `ActionCheckNote`'s
* verify step has something real.
*/
export default async function addTaskNote({
task_id,
text,
author = "bot",
bot_id,
}: Params): Promise<BUN_SQLITE_COBBLE_TASK_NOTES> {
const task = await grabTaskRow({ task_id });
const clean = String(text).trim().slice(0, AppData.TaskNoteMaxLength);
if (!clean) {
throw new AppError(`A note needs some text.`);
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_TASK_NOTES, (typeof BunSQLiteTables)[number]>({
table: "task_notes",
data: [{ task_id: Number(task.id), author, bot_id: bot_id ? Number(bot_id) : undefined, text: clean }],
});
const note_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !note_id) {
throw new Error(`Couldn't save the note: ${res.msg}`);
}
const saved = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({
sql: `SELECT * FROM task_notes WHERE id = ? LIMIT 1`,
values: [note_id],
})
).payload?.[0];
if (!saved) {
throw new Error(`The note didn't save.`);
}
return saved;
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
user_id: number | string;
task_id: number | string;
};
/**
* The task, if the user reaches its bot or workspace: the one check every
* user-facing task endpoint makes before reading or changing it. Throws
* "Task not found" rather than saying a task exists where it doesn't, so
* an unreachable id reads the same as a missing one.
*/
export default async function assertUserReachesTask({
user_id,
task_id,
}: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
values: [Number(task_id), ...access.values],
});
const task = res.payload?.[0];
if (!task?.id) {
throw new AppError(`Task not found.`);
}
return task;
}
@@ -0,0 +1,148 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOT_FILES,
BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_TASK_MEDIA,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { TaskMediaKind } from "@/src/dict/task-dict";
import AppError from "@/src/utils/app-error";
import type { StorageScope } from "@/src/utils/storage-access";
import grabBotFile from "../bot-files/grab-bot-file";
import grabTaskMedia from "./grab-task-media";
import grabTaskRow from "./grab-task-scope";
type Params = {
task_id: number | string;
/**
* One of the run's folders' catalog ids: the file must reach it
* (`grabBotFile`), so a bot can't link a file outside its storage.
*/
file_id?: number | string | null;
/**
* Or a `media` row the user owns (a chat upload being filed onto a
* task). Ignored when `file_id` is given.
*/
media_id?: number | string | null;
/**
* The user whose own media may be linked.
*/
user_id: number | string;
/**
* The run's storage scopes, for a bot's file.
*/
scopes?: StorageScope[];
kind?: TaskMediaKind;
/**
* The bot linking it, when a bot does.
*/
added_by_bot_id?: number | string | null;
};
export type AttachTaskFileResult = {
file: BUN_SQLITE_COBBLE_TASK_MEDIA;
/**
* Whether the link was already there (the call succeeded either way).
*/
existed: boolean;
};
/**
* Links a file to a task as its deliverable or a reference. A bot file is
* resolved through `grabBotFile`, so only what the run reaches links; a
* chat upload must be one of the user's own `media` rows. Caps a task at
* `TaskMediaMaxPerTask` files and never links the same file twice.
*/
export default async function attachTaskFile({
task_id,
file_id,
media_id,
user_id,
scopes = [],
kind = "deliverable",
added_by_bot_id,
}: Params): Promise<AttachTaskFileResult> {
const task = await grabTaskRow({ task_id });
let file: BUN_SQLITE_COBBLE_BOT_FILES | null = null;
let media: BUN_SQLITE_COBBLE_MEDIA | null = null;
if (file_id) {
file = await grabBotFile({ scopes, file_id });
if (!file?.media_id) {
throw new AppError(`File #${file_id} isn't one you can reach.`);
}
media_id = file.media_id;
}
if (!media_id) {
throw new AppError(`Which file? Give its file id.`);
}
media = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
values: [Number(media_id)],
})
).payload?.[0] || null;
if (!media?.id) {
throw new AppError(`That file's bytes are gone.`);
}
/**
* Without a bot file, the media must be one the user owns: never
* someone else's upload.
*/
if (!file && Number(media.user_id || 0) != Number(user_id)) {
throw new AppError(`That file isn't yours to attach.`);
}
const existing = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE task_id = ? AND media_id = ? LIMIT 1`,
values: [Number(task.id), Number(media.id)],
});
if (existing.payload?.[0]) {
return { file: existing.payload[0], existed: true };
}
const count = (await grabTaskMedia({ task_id: task.id! })).length;
if (count >= AppData.TaskMediaMaxPerTask) {
throw new AppError(`Task #${task.id} already carries ${AppData.TaskMediaMaxPerTask} files. Unlink one first.`);
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_TASK_MEDIA, (typeof BunSQLiteTables)[number]>({
table: "task_media",
data: [
{
task_id: Number(task.id),
media_id: Number(media.id),
bot_file_id: file?.id ? Number(file.id) : undefined,
kind,
name: file?.name || media.title || `File ${media.id}`,
added_by_bot_id: added_by_bot_id ? Number(added_by_bot_id) : undefined,
},
],
});
const link_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !link_id) {
throw new Error(`Couldn't link the file: ${res.msg}`);
}
return {
file: (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE id = ? LIMIT 1`,
values: [link_id],
})
).payload![0]!,
existed: false,
};
}
@@ -0,0 +1,87 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS, BunSQLiteTables } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { TaskStatus } from "@/src/dict/task-dict";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role";
import grabTask from "./grab-task";
import taskScopeValues from "./task-scope-values";
type Params = {
/**
* The user whose run or request creates it.
*/
user_id: number | string;
bot_id?: number | string | null;
workspace_id?: number | string | null;
title: string;
description?: string;
status?: TaskStatus;
/**
* The bot creating it with its tool; none for a user's manual add.
*/
created_by_bot_id?: number | string | null;
};
/**
* Adds a task to a bot's own list or a workspace's shared one. A user adds
* one to a bot they chat with or a workspace they're in; a bot's run adds
* one to its own list or to a workspace it works in (its tool checks
* that). Caps a collection at `TaskMaxPerScope` open and finished tasks.
*/
export default async function createTask({
user_id,
bot_id,
workspace_id,
title,
description,
status,
created_by_bot_id,
}: Params): Promise<TaskDetail> {
const scope = taskScopeValues({ bot_id, workspace_id });
const role = await grabCrewRole({ user_id });
if (!created_by_bot_id && role == "member") {
throw new AppError(`Only an admin can add a task.`);
}
const count = Number(
(
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM tasks WHERE ${
scope.bot_id ? `bot_id = ?` : `workspace_id = ?`
}`,
values: [Number(scope.bot_id || scope.workspace_id)],
})
).payload?.[0]?.n || 0,
);
if (count >= AppData.TaskMaxPerScope) {
throw new AppError(`That list is full (${AppData.TaskMaxPerScope} tasks). Finish or delete one first.`);
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_TASKS, (typeof BunSQLiteTables)[number]>({
table: "tasks",
data: [
{
bot_id: scope.bot_id || undefined,
workspace_id: scope.workspace_id || undefined,
title: title.trim().slice(0, AppData.TaskTitleMaxLength),
description: description || undefined,
status: status || "todo",
origin: created_by_bot_id ? "bot" : "user",
created_by_bot_id: created_by_bot_id ? Number(created_by_bot_id) : undefined,
created_by_user_id: Number(user_id),
},
],
});
const task_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !task_id) {
throw new Error(`Couldn't save the task: ${res.msg}`);
}
return await grabTask({ user_id, task_id });
}
@@ -0,0 +1,56 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_MEDIA } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role";
import deleteMedia from "../media/delete-media";
import grabTaskRow from "./grab-task-scope";
type Params = {
user_id: number | string;
task_id: number | string;
/**
* A bot's run deleting its task; none for the user. Either way, the
* caller checked the run reaches it.
*/
deleted_by_bot_id?: number | string | null;
};
/**
* Deletes a task with its notes and file links (cascades). The files
* themselves stay where they are: a `task_media` link is not the bytes'
* owner, so a bot file's folder keeps its file and only the link goes.
*/
export default async function deleteTask({ user_id, task_id, deleted_by_bot_id }: Params) {
const task = await grabTaskRow({ task_id });
const role = await grabCrewRole({ user_id });
if (!deleted_by_bot_id && role == "member") {
throw new AppError(`Only an admin can delete a task.`);
}
/**
* Read before the delete: the links go with the task, and the media
* rows a chat upload left are deleted with them (a bot file's bytes
* belong to its folder, not to the task).
*/
const links = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE task_id = ?`,
values: [Number(task_id)],
})
).payload || [];
const res = await BunSQLite.sql({ sql: `DELETE FROM tasks WHERE id = ?`, values: [Number(task_id)] });
if (!res.success) {
throw new AppError(`Couldn't delete the task: ${res.msg || res.error}`);
}
const uploads = links.filter((link) => !link.bot_file_id).map((link) => Number(link.media_id));
if (uploads.length) {
await deleteMedia({ ids: uploads, user_id, can_delete_all_media: true }).catch(() => {});
}
return task;
}
@@ -0,0 +1,70 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_MEDIA } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import deleteMedia from "../media/delete-media";
type Params = {
task_id: number | string;
/**
* The link's own id, or the media it points at.
*/
task_media_id?: number | string | null;
media_id?: number | string | null;
user_id: number | string;
};
/**
* Unlinks one file from a task. A chat upload that no other task or
* message uses has its bytes deleted with it; a bot file keeps its bytes
* in its folder, which is the file's real home.
*/
export default async function detachTaskFile({
task_id,
task_media_id,
media_id,
user_id,
}: Params) {
const link = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE task_id = ? AND ${
task_media_id ? "id = ?" : "media_id = ?"
} LIMIT 1`,
values: [Number(task_id), Number(task_media_id || media_id)],
})
).payload?.[0];
if (!link?.id) {
throw new AppError(`That file isn't on this task.`);
}
const res = await BunSQLite.sql({
sql: `DELETE FROM task_media WHERE id = ?`,
values: [Number(link.id)],
});
if (!res.success) {
throw new AppError(`Couldn't unlink the file: ${res.msg || res.error}`);
}
if (!link.bot_file_id && link.media_id) {
const still_used = (
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM task_media WHERE media_id = ?`,
values: [Number(link.media_id)],
})
).payload?.[0]?.n;
const in_messages = (
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM bot_message_media WHERE media_id = ?`,
values: [Number(link.media_id)],
})
).payload?.[0]?.n;
if (!Number(still_used || 0) && !Number(in_messages || 0)) {
await deleteMedia({ id: link.media_id, user_id, can_delete_all_media: true }).catch(() => {});
}
}
return link;
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import findDuplicateTask from "./find-duplicate-task";
function task(fields: Partial<BUN_SQLITE_COBBLE_TASKS>): BUN_SQLITE_COBBLE_TASKS {
return { id: 1, title: "Check the invoices", status: "todo", ...fields };
}
describe("findDuplicateTask", () => {
test("finds the open task with the same title in the same bot's list", () => {
const tasks = [task({ id: 3, bot_id: 2, title: " check the INVOICES " })];
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 2 })?.id).toBe(3);
});
test("a finished or cancelled task is never a duplicate", () => {
const tasks = [
task({ id: 1, bot_id: 2, status: "done" }),
task({ id: 2, bot_id: 2, status: "cancelled" }),
];
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 2 })).toBeNull();
});
test("keeps bot lists and workspace lists apart", () => {
const tasks = [task({ id: 5, bot_id: 2 }), task({ id: 6, workspace_id: 9 })];
expect(findDuplicateTask({ tasks, title: "Check the invoices", workspace_id: 9 })?.id).toBe(6);
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 2 })?.id).toBe(5);
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 7 })).toBeNull();
});
});
@@ -0,0 +1,33 @@
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
type Params = {
tasks: BUN_SQLITE_COBBLE_TASKS[];
title: string;
/**
* Same collection: a bot's own list, or a workspace's.
*/
bot_id?: number | null;
workspace_id?: number | null;
};
/**
* A task in the same collection with the same title that's still open, so
* `ActionCheckNote` gets real behaviour: a bot updates the existing task
* instead of adding a second one for the same work.
*/
export default function findDuplicateTask({ tasks, title, bot_id, workspace_id }: Params) {
const wanted = title.trim().toLowerCase();
return (
tasks.find((task) => {
if (task.status == "done" || task.status == "cancelled") return false;
if (workspace_id) {
if (Number(task.workspace_id || 0) != Number(workspace_id)) return false;
} else if (Number(task.bot_id || 0) != Number(bot_id || 0)) {
return false;
}
return (task.title || "").trim().toLowerCase() == wanted;
}) || null
);
}
@@ -0,0 +1,40 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import botTaskScopesSql from "@/src/utils/task-access/bot-task-scopes-sql";
type Params = {
task_id: number | string;
/**
* The bot whose run is asking.
*/
bot_id: number | string;
is_coordinator: boolean;
};
/**
* The task, if the bot's run reaches it: its own, a workspace it works in,
* or anything at all for the coordinator. The gate every task tool makes
* before reading or changing one, so a tool argument can never widen a
* run's reach.
*/
export default async function grabRunTask({
task_id,
bot_id,
is_coordinator,
}: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
const access = botTaskScopesSql({ bot_id, is_coordinator });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
values: [Number(task_id), ...access.values],
});
const task = res.payload?.[0];
if (!task?.id) {
throw new AppError(`Task #${task_id} isn't one you can work on.`);
}
return task;
}
@@ -0,0 +1,52 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_TASK_MEDIA,
} from "@/db/types/db";
import grabCrewRole from "../access/grab-crew-role";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
type Params = {
media_id: number | string;
user_id: number | string;
};
/**
* A task file's bytes (its private `media` row) for a user who reaches the
* task's bot or workspace, the same rule the task list uses. Task media
* belongs to the crew, like the crew itself, so whoever sees the task sees
* what it produced.
*/
export default async function grabTaskMediaFile({
media_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_MEDIA | null> {
const link = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE media_id = ? LIMIT 1`,
values: [Number(media_id)],
})
).payload?.[0];
if (!link?.task_id) return null;
const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id });
const task = (
await BunSQLite.sql<{ id: number }>({
sql: `SELECT t.id FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
values: [Number(link.task_id), ...access.values],
})
).payload?.[0];
if (!task?.id) return null;
const media = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
values: [Number(media_id)],
})
).payload?.[0];
return media || null;
}
@@ -0,0 +1,29 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TaskFile } from "@/src/types";
type Params = {
task_id: number | string;
};
/**
* A task's files with what a row shows: the `media` row's type, mime and
* size, and whether that row is still there (a failed cleanup or a
* deleted file leaves the link behind, listed as missing rather than
* hiding it).
*/
export default async function grabTaskMedia({ task_id }: Params): Promise<TaskFile[]> {
const res = await BunSQLite.sql<TaskFile>({
sql: `SELECT tm.*, m.media_type, m.mime_type, m.title AS media_title
FROM task_media tm
LEFT JOIN media m ON m.id = tm.media_id
WHERE tm.task_id = ?
ORDER BY tm.kind ASC, tm.id ASC`,
values: [Number(task_id)],
});
return (res.payload || []).map((file) => ({
...file,
name: file.name || file.media_title || `File ${file.media_id}`,
missing: !file.media_type,
}));
}
@@ -0,0 +1,80 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_TASKS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import type { TaskScope } from "@/src/utils/task-access/types";
/**
* Reads a task's row as it is, without an access check: for the helpers
* that already checked who is asking. Throws when it's gone.
*/
export default async function grabTaskRow({ task_id }: { task_id: number | string }) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT * FROM tasks WHERE id = ? LIMIT 1`,
values: [Number(task_id)],
});
const task = res.payload?.[0];
if (!task?.id) {
throw new AppError(`Task not found.`);
}
return task;
}
/**
* A task's collection (`bot_id` or `workspace_id`), for the access checks
* that follow it.
*/
export function taskScopeOf({ task }: { task: BUN_SQLITE_COBBLE_TASKS }): TaskScope {
return {
bot_id: task.bot_id ? Number(task.bot_id) : null,
workspace_id: task.workspace_id ? Number(task.workspace_id) : null,
};
}
/**
* Whether a task is still open (anything but done or cancelled).
*/
export function isOpenTask({ status }: { status?: string | null }) {
return status != "done" && status != "cancelled";
}
export type TaskScopeTarget =
| { kind: "bot"; bot: BUN_SQLITE_COBBLE_BOTS }
| { kind: "workspace"; workspace: BUN_SQLITE_COBBLE_WORKSPACES };
/**
* The bot or workspace a task collection names, read as it is. Throws when
* it's gone, so a create never points at nothing.
*/
export async function grabTaskTarget(params: {
bot_id?: number | null;
workspace_id?: number | null;
}): Promise<TaskScopeTarget> {
if (params.bot_id) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? LIMIT 1`,
values: [Number(params.bot_id)],
});
const bot = res.payload?.[0];
if (!bot?.id) throw new AppError(`Bot not found.`);
return { kind: "bot", bot };
}
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT * FROM workspaces WHERE id = ? LIMIT 1`,
values: [Number(params.workspace_id)],
});
const workspace = res.payload?.[0];
if (!workspace?.id) throw new AppError(`Workspace not found.`);
return { kind: "workspace", workspace };
}
@@ -0,0 +1,53 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_NOTES } from "@/db/types/db";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import botFaceSql from "@/src/utils/bot-face-sql";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
import grabCrewRole from "../access/grab-crew-role";
import grabTaskMedia from "./grab-task-media";
type Params = {
/**
* The user asking: they must reach the task's bot or workspace.
*/
user_id: number | string;
task_id: number | string;
};
/**
* One task with its notes (oldest first) and files, only if the user
* reaches the bot or workspace it belongs to (`taskScopesSql`, the same
* condition the lists use).
*/
export default async function grabTask({ user_id, task_id }: Params): Promise<TaskDetail> {
const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id });
const face = botFaceSql({ user_id, alias: "b", prefix: "bot_" });
const row_res = await BunSQLite.sql<TaskDetail>({
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name
FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id
LEFT JOIN workspaces w ON w.id = t.workspace_id
WHERE t.id = ? AND ${access.sql}
LIMIT 1`,
values: [...face.values, Number(task_id), ...access.values],
});
const row = row_res.payload?.[0];
if (!row) {
throw new AppError(`Task not found.`);
}
const [notes_res, files] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({
sql: `SELECT * FROM task_notes WHERE task_id = ? ORDER BY id ASC`,
values: [Number(task_id)],
}),
grabTaskMedia({ task_id }),
]);
return { ...row, notes: notes_res.payload || [], files };
}
@@ -0,0 +1,101 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import { OpenTaskStatuses } from "@/src/dict/task-dict";
import type { TaskWithNames } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
import botTaskScopesSql from "@/src/utils/task-access/bot-task-scopes-sql";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
import type { SqlCondition } from "@/src/utils/task-access/types";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
/**
* A user's own view (`taskScopesSql`): their bots and workspaces.
*/
user_id?: number | string;
/**
* Or a bot run's view (`botTaskScopesSql`): its own and its
* workspaces', or every task for the coordinator.
*/
bot_id?: number | string;
is_coordinator?: boolean;
/**
* Only one collection.
*/
only_bot_id?: number | string | null;
only_workspace_id?: number | string | null;
/**
* Only these statuses; none for every one.
*/
statuses?: string[];
/**
* Only the open ones (todo, doing, blocked).
*/
open_only?: boolean;
limit?: number;
};
/**
* Tasks with what a list shows beside them: the bot (its face as this user
* sees it), the workspace and the newest note. Open ones first, blocked
* before doing before to do, then the oldest.
*/
export default async function grabTasks({
user_id,
bot_id,
is_coordinator,
only_bot_id,
only_workspace_id,
statuses,
open_only,
limit,
}: Params): Promise<TaskWithNames[]> {
const where: string[] = [];
const values: (string | number)[] = [];
if (user_id != null) {
const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id });
where.push(access.sql);
values.push(...access.values);
} else if (bot_id != null) {
const access = botTaskScopesSql({ bot_id, is_coordinator: Boolean(is_coordinator) });
where.push(access.sql);
values.push(...access.values);
}
if (only_bot_id) {
where.push(`t.bot_id = ?`);
values.push(Number(only_bot_id));
}
if (only_workspace_id) {
where.push(`t.workspace_id = ?`);
values.push(Number(only_workspace_id));
}
const wanted = open_only ? [...OpenTaskStatuses] : statuses?.length ? statuses : null;
if (wanted) {
where.push(`t.status IN (${wanted.map(() => "?").join(", ")})`);
values.push(...wanted);
}
const face = botFaceSql({ user_id: user_id || 0, alias: "b", prefix: "bot_" });
const res = await BunSQLite.sql<TaskWithNames>({
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
(SELECT n.text FROM task_notes n WHERE n.task_id = t.id ORDER BY n.id DESC LIMIT 1) AS latest_note,
(SELECT COUNT(*) FROM task_media tm WHERE tm.task_id = t.id) AS media_count
FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id
LEFT JOIN workspaces w ON w.id = t.workspace_id
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY CASE t.status WHEN 'blocked' THEN 0 WHEN 'doing' THEN 1 WHEN 'todo' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
t.updated_at DESC, t.id DESC
${limit ? `LIMIT ${Number(limit)}` : ""}`,
values: [...face.values, ...values],
});
return res.payload || [];
}
@@ -0,0 +1,34 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_MEDIA } from "@/db/types/db";
type Params = {
/**
* A bot's own tasks, or a workspace's.
*/
bot_id?: number | string | null;
workspace_id?: number | string | null;
};
/**
* The bytes a bot's or workspace's tasks filed that no folder owns (a chat
* upload linked to a task). Read before the bot or workspace is deleted:
* the tasks and their links cascade, and nothing else would ever clean
* those files up. A bot file is left alone: its folder is its home.
*/
export async function grabTaskMediaIds({ bot_id, workspace_id }: Params): Promise<number[]> {
const column = workspace_id ? "workspace_id" : "bot_id";
const owner = workspace_id || bot_id;
if (!owner) return [];
const links = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT tm.media_id FROM task_media tm
JOIN tasks t ON t.id = tm.task_id
WHERE t.${column} = ? AND tm.bot_file_id IS NULL`,
values: [Number(owner)],
})
).payload || [];
return links.map((link) => Number(link.media_id)).filter(Boolean);
}
@@ -0,0 +1,27 @@
import AppError from "@/src/utils/app-error";
type Params = {
bot_id?: number | string | null;
workspace_id?: number | string | null;
};
/**
* A task belongs to exactly one collection: a bot's own list, or a
* workspace's shared one. Checked in the helpers rather than the schema,
* since SQLite treats NULL in a unique constraint as always distinct (the
* `envs` precedent).
*/
export default function taskScopeValues({ bot_id, workspace_id }: Params) {
const bot = bot_id ? Number(bot_id) : null;
const workspace = workspace_id ? Number(workspace_id) : null;
if (bot && workspace) {
throw new AppError(`A task belongs to a bot or a workspace, not both.`);
}
if (!bot && !workspace) {
throw new AppError(`Say which bot or workspace the task is for.`);
}
return { bot_id: bot, workspace_id: workspace };
}
@@ -0,0 +1,95 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import type { TaskStatus } from "@/src/dict/task-dict";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role";
import grabTask from "./grab-task";
import grabTaskRow, { isOpenTask } from "./grab-task-scope";
type Params = {
user_id: number | string;
task_id: number | string;
title?: string;
description?: string;
status?: TaskStatus;
/**
* What the user has to do to unblock it. Cleared when the status leaves
* `blocked`.
*/
blocked_reason?: string | null;
/**
* The bot's run changing it; none for the user. Either way, the caller
* checked the run or user reaches it.
*/
updated_by_bot_id?: number | string | null;
};
/**
* Changes a task's title, brief or status. Moving it to done or cancelled
* stamps `completed_at`; moving it back to an open status clears it.
* `blocked_reason` only survives while the task is blocked.
*/
export default async function updateTask({
user_id,
task_id,
title,
description,
status,
blocked_reason,
updated_by_bot_id,
}: Params): Promise<TaskDetail> {
/**
* Tasks are crew structure like bots and workspaces: an admin changes
* them by hand, a bot's run changes its own.
*/
if (!updated_by_bot_id && (await grabCrewRole({ user_id })) == "member") {
throw new AppError(`Only an admin can change a task.`);
}
const task = await grabTaskRow({ task_id });
const fields: string[] = [];
const values: (string | number | null)[] = [];
if (title != null) {
fields.push(`title = ?`);
values.push(String(title).trim().slice(0, AppData.TaskTitleMaxLength));
}
if (description != null) {
fields.push(`description = ?`);
values.push(String(description).slice(0, AppData.TaskDescriptionMaxLength));
}
if (status != null) {
fields.push(`status = ?`);
values.push(status);
fields.push(`completed_at = ?`);
values.push(isOpenTask({ status }) ? null : Date.now());
if (status != "blocked") {
fields.push(`blocked_reason = ?`);
values.push(null);
}
}
if (blocked_reason !== undefined && (status ?? task.status) == "blocked") {
fields.push(`blocked_reason = ?`);
values.push(blocked_reason ? String(blocked_reason).slice(0, AppData.TaskNoteMaxLength) : null);
}
if (!fields.length) {
return await grabTask({ user_id, task_id });
}
const res = await BunSQLite.sql({
sql: `UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`,
values: [...values, Number(task_id)],
});
if (!res.success) {
throw new AppError(`Couldn't save the task: ${res.msg || res.error}`);
}
return await grabTask({ user_id, task_id });
}
@@ -1,5 +1,6 @@
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
@@ -30,6 +31,10 @@ export default async function deleteWorkspace({ user_id, workspace_id }: Params)
scope: "workspace",
owner_id: workspace.id!,
}).catch(() => []);
/**
* And what its tasks filed outside a folder.
*/
const task_media_ids = await grabTaskMediaIds({ workspace_id: workspace.id! }).catch(() => []);
const res = await BunSQLite.sql({
sql: `DELETE FROM workspaces WHERE id = ?`,
@@ -44,6 +49,13 @@ export default async function deleteWorkspace({ user_id, workspace_id }: Params)
await deleteMedia({ id: workspace.image_media_id, user_id: Number(user_id) }).catch(() => {});
}
/**
* What the workspace's tasks filed and no folder owns (a chat upload).
*/
await deleteMedia({ ids: task_media_ids, user_id: Number(user_id), can_delete_all_media: true }).catch(
() => {},
);
/**
* The bots' shared folder there goes with it.
*/