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:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import userAuth from "../../backend/auth/user-auth";
|
||||
import grabSharedPictureMedia from "@/src/functions/backend/db/access/grab-shared-picture-media";
|
||||
import grabBotFileMedia from "@/src/functions/backend/db/bot-files/grab-bot-file-media";
|
||||
import grabTaskMediaFile from "@/src/functions/backend/db/tasks/grab-task-media-file";
|
||||
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
|
||||
@@ -54,6 +55,7 @@ export default async function handleMediaServer({
|
||||
media_res.singleRes ||
|
||||
(media_id ? await grabSharedPictureMedia({ media_id, user_id: user.id! }) : null) ||
|
||||
(media_id ? await grabBotFileMedia({ media_id, user_id: user.id! }) : null) ||
|
||||
(media_id ? await grabTaskMediaFile({ media_id, user_id: user.id! }) : null) ||
|
||||
undefined;
|
||||
} else {
|
||||
const media_res = await BunSQLite.select<
|
||||
|
||||
Reference in New Issue
Block a user