Scope workspace tasks to the bot that has them
A workspace's task now carries the bot that has it there (bot_id with workspace_id), or none while it's shared. A bot reaches its own list and, in its workspaces, its own tasks plus the shared ones; a workspace admin every task there. Bots create workspace tasks for themselves by default, take a shared one by starting it, and hand-overs record the new bot. Removing or deleting a bot makes its workspace tasks shared again. The conversation's Tasks modal shows that bot's tasks in the workspace. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -8,8 +8,8 @@ type Params = {
|
||||
*/
|
||||
is_coordinator: boolean;
|
||||
/**
|
||||
* The workspaces the bot is admin of: it also reaches the tasks it added
|
||||
* to their members' own lists.
|
||||
* The workspaces the bot is admin of: it reaches every task there,
|
||||
* whoever has it.
|
||||
*/
|
||||
admin_workspace_ids?: number[];
|
||||
/**
|
||||
@@ -19,11 +19,11 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The tasks a bot's run reaches: its own, plus every workspace it works in
|
||||
* (a workspace's tasks are shared by its bots). The coordinator reaches
|
||||
* every task in the install: every bot's own and every workspace's. A
|
||||
* workspace admin also reaches the tasks it put on that workspace's
|
||||
* members' own lists.
|
||||
* The tasks a bot's run reaches: its own list, and in every workspace it
|
||||
* works in the tasks it has there plus the shared ones nobody has yet.
|
||||
* Another bot's task in a workspace is that bot's. A workspace admin
|
||||
* reaches every task in that workspace; the coordinator every task in the
|
||||
* install.
|
||||
*/
|
||||
export default function botTaskScopesSql({
|
||||
bot_id,
|
||||
@@ -34,31 +34,18 @@ export default function botTaskScopesSql({
|
||||
if (is_coordinator) return { sql: `1`, values: [] };
|
||||
|
||||
const admin_ids = admin_workspace_ids.map(Number).filter(Boolean);
|
||||
|
||||
if (admin_ids.length) {
|
||||
const marks = admin_ids.map(() => "?").join(", ");
|
||||
|
||||
return {
|
||||
sql: `(
|
||||
${alias}.bot_id = ?
|
||||
OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN (
|
||||
SELECT workspace_id FROM workspace_bots WHERE bot_id = ?
|
||||
))
|
||||
OR (${alias}.created_by_bot_id = ? AND ${alias}.bot_id IN (
|
||||
SELECT bot_id FROM workspace_bots WHERE workspace_id IN (${marks})
|
||||
))
|
||||
)`,
|
||||
values: [Number(bot_id), Number(bot_id), Number(bot_id), ...admin_ids],
|
||||
};
|
||||
}
|
||||
const admin_sql = admin_ids.length
|
||||
? `OR ${alias}.workspace_id IN (${admin_ids.map(() => "?").join(", ")})`
|
||||
: "";
|
||||
|
||||
return {
|
||||
sql: `(
|
||||
${alias}.bot_id = ?
|
||||
OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN (
|
||||
SELECT workspace_id FROM workspace_bots WHERE bot_id = ?
|
||||
))
|
||||
(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)
|
||||
OR (${alias}.workspace_id IS NOT NULL
|
||||
AND (${alias}.bot_id IS NULL OR ${alias}.bot_id = ?)
|
||||
AND ${alias}.workspace_id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?))
|
||||
${admin_sql}
|
||||
)`,
|
||||
values: [Number(bot_id), Number(bot_id)],
|
||||
values: [Number(bot_id), Number(bot_id), Number(bot_id), ...admin_ids],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import workspacesSql from "../crew-access/workspaces-sql";
|
||||
import type { SqlCondition, TaskScopeSqlParams } from "./types";
|
||||
|
||||
/**
|
||||
* The tasks a user sees: a bot they chat with in its own chat, and every
|
||||
* workspace they're in (whose tasks belong to the workspace, not to one
|
||||
* bot). Matches the crew access rules exactly, so a task can't be seen
|
||||
* where the bot or workspace cannot.
|
||||
* The tasks a user sees: the own list of a bot they chat with in its own
|
||||
* chat, and every task of a workspace they're in, whichever bot there has
|
||||
* it. Matches the crew access rules exactly, so a task can't be seen where
|
||||
* the bot or workspace cannot.
|
||||
*/
|
||||
export default function taskScopesSql({
|
||||
role,
|
||||
@@ -18,7 +18,7 @@ export default function taskScopesSql({
|
||||
|
||||
return {
|
||||
sql: `(
|
||||
(${alias}.bot_id IS NOT NULL AND ${alias}.bot_id IN (SELECT tb.id FROM bots tb WHERE ${bots.sql}))
|
||||
(${alias}.workspace_id IS NULL AND ${alias}.bot_id IN (SELECT tb.id FROM bots tb WHERE ${bots.sql}))
|
||||
OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN (SELECT tw.id FROM workspaces tw WHERE ${workspaces.sql}))
|
||||
)`,
|
||||
values: [...bots.values, ...workspaces.values],
|
||||
|
||||
@@ -21,7 +21,8 @@ beforeAll(() => {
|
||||
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER, created_by_bot_id INTEGER)`);
|
||||
db.run(`INSERT INTO tasks VALUES
|
||||
(100, 1, NULL, NULL), (101, 2, NULL, NULL), (102, 3, NULL, NULL),
|
||||
(110, NULL, 10, NULL), (111, NULL, 11, NULL)`);
|
||||
(110, NULL, 10, NULL), (111, NULL, 11, NULL),
|
||||
(112, 3, 11, NULL), (113, 2, 11, NULL)`);
|
||||
});
|
||||
|
||||
function ids({ condition }: { condition: SqlCondition }) {
|
||||
@@ -34,43 +35,53 @@ function ids({ condition }: { condition: SqlCondition }) {
|
||||
describe("taskScopesSql", () => {
|
||||
test("the super admin sees every task, the coordinator's and every workspace's", () => {
|
||||
expect(ids({ condition: taskScopesSql({ role: "super_admin", user_id: 1 }) })).toEqual([
|
||||
100, 101, 102, 110, 111,
|
||||
100, 101, 102, 110, 111, 112, 113,
|
||||
]);
|
||||
});
|
||||
|
||||
test("an admin sees every worker's own tasks and every workspace's, never the coordinator's", () => {
|
||||
expect(ids({ condition: taskScopesSql({ role: "admin", user_id: 5 }) })).toEqual([101, 102, 110, 111]);
|
||||
expect(ids({ condition: taskScopesSql({ role: "admin", user_id: 5 }) })).toEqual([101, 102, 110, 111, 112, 113]);
|
||||
});
|
||||
|
||||
test("a member sees only their assigned bots' own tasks and their workspaces'", () => {
|
||||
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 7 }) })).toEqual([101, 111]);
|
||||
test("a member sees only their assigned bots' own tasks and every task of their workspaces", () => {
|
||||
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 7 }) })).toEqual([101, 111, 112, 113]);
|
||||
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 8 }) })).toEqual([]);
|
||||
});
|
||||
|
||||
test("chatting with a bot doesn't show its tasks in a workspace the user isn't in", () => {
|
||||
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 9 }) })).toEqual([102]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botTaskScopesSql", () => {
|
||||
test("a worker reaches its own tasks and those of every workspace it works in", () => {
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 111]);
|
||||
test("a worker reaches its own list, its tasks in its workspaces and the shared ones there", () => {
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 111, 112]);
|
||||
});
|
||||
|
||||
test("another bot's task in a workspace stays that bot's", () => {
|
||||
db.run(`INSERT INTO workspace_bots VALUES (11, 2)`);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101, 111, 113]);
|
||||
db.run(`DELETE FROM workspace_bots WHERE bot_id = 2`);
|
||||
});
|
||||
|
||||
test("a task given to a bot in a workspace it has left is out of its reach", () => {
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101]);
|
||||
});
|
||||
|
||||
test("the coordinator reaches every task in the install", () => {
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 1, is_coordinator: true }) })).toEqual([
|
||||
100, 101, 102, 110, 111,
|
||||
100, 101, 102, 110, 111, 112, 113,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botTaskScopesSql for a workspace admin", () => {
|
||||
test("it also reaches what it put on its workspace members' own lists", () => {
|
||||
test("it reaches every task in that workspace, whoever has it, and no member's own list", () => {
|
||||
db.run(`INSERT INTO workspace_bots VALUES (11, 4)`);
|
||||
db.run(`INSERT INTO tasks VALUES (103, 3, NULL, 4), (104, 2, NULL, 4)`);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false, admin_workspace_ids: [11] }) })).toEqual([
|
||||
103, 111,
|
||||
111, 112, 113,
|
||||
]);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([111]);
|
||||
db.run(`DELETE FROM workspace_bots WHERE bot_id = 4`);
|
||||
db.run(`DELETE FROM tasks WHERE id IN (103, 104)`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ export type SqlCondition = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One collection of tasks: a bot's own, or a workspace's shared one.
|
||||
* Where a task is: a bot's own list (`bot_id` alone), or a workspace
|
||||
* (`workspace_id`, with the bot that has it, if any).
|
||||
*/
|
||||
export type TaskScope = {
|
||||
bot_id?: number | null;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import taskPlaceLabel from "./task-place-label";
|
||||
|
||||
describe("taskPlaceLabel", () => {
|
||||
test("names the bot for its own list", () => {
|
||||
expect(taskPlaceLabel({ task: { bot_id: 2, bot_name: "Ada" } })).toBe("Ada");
|
||||
});
|
||||
|
||||
test("names the bot and the workspace for a bot's task there", () => {
|
||||
expect(taskPlaceLabel({ task: { bot_id: 2, bot_name: "Ada", workspace_id: 5, workspace_name: "Launch" } })).toBe(
|
||||
"Ada in Launch",
|
||||
);
|
||||
});
|
||||
|
||||
test("says shared for a workspace task nobody has yet", () => {
|
||||
expect(taskPlaceLabel({ task: { workspace_id: 5, workspace_name: "Launch" } })).toBe("Shared in Launch");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
type Params = {
|
||||
task: {
|
||||
bot_id?: number | string | null;
|
||||
bot_name?: string | null;
|
||||
workspace_id?: number | string | null;
|
||||
workspace_name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a task is and who has it, for a list row: "Ada" for a bot's own
|
||||
* list, "Ada in Launch" for a bot's task in a workspace, "Shared in
|
||||
* Launch" for one nobody there has yet.
|
||||
*/
|
||||
export default function taskPlaceLabel({ task }: Params) {
|
||||
const bot = task.bot_id ? task.bot_name || `Bot ${task.bot_id}` : null;
|
||||
|
||||
if (!task.workspace_id) return bot || "";
|
||||
|
||||
const workspace = task.workspace_name || `Workspace ${task.workspace_id}`;
|
||||
|
||||
return `${bot || "Shared"} in ${workspace}`;
|
||||
}
|
||||
Reference in New Issue
Block a user