From 479700ed6f73e15057c6fef630b46a353baffe8f Mon Sep 17 00:00:00 2001 From: Philippe Rolet Date: Mon, 27 Nov 2023 15:51:44 +0100 Subject: [PATCH] [Ahuna] MemberAgentVisibility table (#2674) * table model * constraints * updated constraints * post method * delete * rename * move * initdb * assistantId * spolu review * renaming after discussion --- front/admin/db.ts | 6 +- .../lib/api/assistant/visibility_override.ts | 76 +++++++ front/lib/error.ts | 1 + front/lib/models/assistant/agent.ts | 82 +++++++- .../me/assistant_visibility_override.ts | 192 ++++++++++++++++++ front/types/assistant/agent.ts | 7 + 6 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 front/lib/api/assistant/visibility_override.ts create mode 100644 front/pages/api/w/[wId]/members/me/assistant_visibility_override.ts diff --git a/front/admin/db.ts b/front/admin/db.ts index f043bcb1a598..bf9ed0f969d8 100644 --- a/front/admin/db.ts +++ b/front/admin/db.ts @@ -36,7 +36,10 @@ import { XP1Run, XP1User, } from "@app/lib/models"; -import { GlobalAgentSettings } from "@app/lib/models/assistant/agent"; +import { + GlobalAgentSettings, + MemberAgentVisibility, +} from "@app/lib/models/assistant/agent"; import { ContentFragment } from "@app/lib/models/assistant/conversation"; import { PlanInvitation } from "@app/lib/models/plan"; @@ -69,6 +72,7 @@ async function main() { await AgentRetrievalConfiguration.sync({ alter: true }); await AgentDataSourceConfiguration.sync({ alter: true }); await AgentConfiguration.sync({ alter: true }); + await MemberAgentVisibility.sync({ alter: true }); await GlobalAgentSettings.sync({ alter: true }); await AgentRetrievalAction.sync({ alter: true }); diff --git a/front/lib/api/assistant/visibility_override.ts b/front/lib/api/assistant/visibility_override.ts new file mode 100644 index 000000000000..f5d4982dcde5 --- /dev/null +++ b/front/lib/api/assistant/visibility_override.ts @@ -0,0 +1,76 @@ +import { getAgentConfiguration } from "@app/lib/api/assistant/configuration"; +import { Authenticator } from "@app/lib/auth"; +import { Membership } from "@app/lib/models"; +import { MemberAgentVisibility } from "@app/lib/models/assistant/agent"; +import { Err, Ok, Result } from "@app/lib/result"; +import { AgentVisibilityOverrideType } from "@app/types/assistant/agent"; + +export async function setVisibilityOverrideForUser({ + auth, + agentId, + visibility, +}: { + auth: Authenticator; + agentId: string; + visibility: AgentVisibilityOverrideType; +}): Promise< + Result< + { visibility: AgentVisibilityOverrideType; created: boolean | null }, + Error + > +> { + const agentConfiguration = await getAgentConfiguration(auth, agentId); + if (!agentConfiguration) + return new Err(new Error(`Could not find agent configuration ${agentId}`)); + const [userId, workspaceId] = [auth.user()?.id, auth.workspace()?.id]; + const membership = await Membership.findOne({ + where: { userId, workspaceId }, + }); + if (!membership) + return new Err( + new Error(`Could not find membership (user ${userId} ws ${workspaceId})`) + ); + + const [memberAgentVisibility, created] = await MemberAgentVisibility.upsert({ + membershipId: membership.id, + agentConfigurationId: agentConfiguration.id, + visibility: visibility, + }); + return new Ok({ visibility: memberAgentVisibility.visibility, created }); +} + +export async function deleteVisibilityOverrideForUser({ + auth, + agentId, +}: { + auth: Authenticator; + agentId: string; +}): Promise> { + const agentConfiguration = await getAgentConfiguration(auth, agentId); + if (!agentConfiguration) + return new Err(new Error(`Could not find agent configuration ${agentId}`)); + const [userId, workspaceId] = [auth.user()?.id, auth.workspace()?.id]; + const membership = await Membership.findOne({ + where: { userId, workspaceId }, + }); + if (!membership) + return new Err( + new Error(`Could not find membership (user ${userId} ws ${workspaceId})`) + ); + + if (agentConfiguration.scope === "private") { + return new Err( + new Error( + "Cannot remove visibility entry for a 'private'-scope assistant. Please delete the assistant instead." + ) + ); + } + await MemberAgentVisibility.destroy({ + where: { + membershipId: membership.id, + agentConfigurationId: agentConfiguration.id, + }, + }); + + return new Ok({ success: true }); +} diff --git a/front/lib/error.ts b/front/lib/error.ts index e77abc6e6d2f..86cb2292cd8c 100644 --- a/front/lib/error.ts +++ b/front/lib/error.ts @@ -32,6 +32,7 @@ export type APIErrorType = | "workspace_not_found" | "action_unknown_error" | "action_api_error" + | "membership_not_found" | "invitation_not_found" | "plan_limit_error" | "template_not_found" diff --git a/front/lib/models/assistant/agent.ts b/front/lib/models/assistant/agent.ts index 9cc6d70a9609..6f9f38f098e0 100644 --- a/front/lib/models/assistant/agent.ts +++ b/front/lib/models/assistant/agent.ts @@ -1,3 +1,4 @@ +import assert from "assert"; import { CreationOptional, DataTypes, @@ -10,11 +11,12 @@ import { import { front_sequelize } from "@app/lib/databases"; import { AgentRetrievalConfiguration } from "@app/lib/models/assistant/actions/retrieval"; -import { Workspace } from "@app/lib/models/workspace"; +import { Membership, Workspace } from "@app/lib/models/workspace"; import { DustAppRunConfigurationType } from "@app/types/assistant/actions/dust_app_run"; import { AgentConfigurationScope, AgentStatus, + AgentVisibilityOverrideType, GlobalAgentStatus, } from "@app/types/assistant/agent"; @@ -284,3 +286,81 @@ Workspace.hasMany(GlobalAgentSettings, { GlobalAgentSettings.belongsTo(Workspace, { foreignKey: { name: "workspaceId", allowNull: false }, }); + +export class MemberAgentVisibility extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + + declare visibility: AgentVisibilityOverrideType; + + declare membershipId: ForeignKey; + declare agentConfigurationId: ForeignKey; +} + +MemberAgentVisibility.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + + visibility: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + modelName: "member_agent_visibility", + sequelize: front_sequelize, + indexes: [ + { fields: ["membershipId"] }, + { fields: ["agentConfigurationId", "membershipId"], unique: true }, + ], + } +); + +Membership.hasMany(MemberAgentVisibility, { + foreignKey: { allowNull: false }, + onDelete: "CASCADE", +}); +AgentConfiguration.hasMany(MemberAgentVisibility, { + foreignKey: { allowNull: false }, + onDelete: "CASCADE", +}); + +MemberAgentVisibility.addHook( + "beforeCreate", + "only_one_listing_for_private_agent", + async (memberAgentList) => { + const agentConfiguration = await AgentConfiguration.findOne({ + where: { + id: memberAgentList.dataValues.agentConfigurationId, + }, + }); + if (!agentConfiguration) + throw new Error("Unexpected: Agent configuration not found"); + if (agentConfiguration.scope === "private") { + const existingMemberAgentList = await MemberAgentVisibility.findOne({ + where: { + agentConfigurationId: memberAgentList.dataValues.agentConfigurationId, + }, + }); + assert(!existingMemberAgentList); + } + } +); diff --git a/front/pages/api/w/[wId]/members/me/assistant_visibility_override.ts b/front/pages/api/w/[wId]/members/me/assistant_visibility_override.ts new file mode 100644 index 000000000000..68e5b824918a --- /dev/null +++ b/front/pages/api/w/[wId]/members/me/assistant_visibility_override.ts @@ -0,0 +1,192 @@ +import { isLeft } from "fp-ts/lib/Either"; +import * as t from "io-ts"; +import * as reporter from "io-ts-reporters"; +import { NextApiRequest, NextApiResponse } from "next"; + +import { getAgentConfiguration } from "@app/lib/api/assistant/configuration"; +import { + deleteVisibilityOverrideForUser, + setVisibilityOverrideForUser, +} from "@app/lib/api/assistant/visibility_override"; +import { Authenticator, getSession } from "@app/lib/auth"; +import { Membership } from "@app/lib/models"; +import { apiError, withLogging } from "@app/logger/withlogging"; +import { AgentVisibilityOverrideType } from "@app/types/assistant/agent"; + +export type PostMemberAssistantVisibilityResponseBody = { + created: boolean | null; + visibility: AgentVisibilityOverrideType; +}; + +export const PostMemberAssistantVisibilityRequestBodySchema = t.type({ + assistantId: t.string, + visibility: t.union([ + t.literal("workspace-unlisted"), + t.literal("published-listed"), + ]), +}); + +export const DeleteMemberAssistantsVisibilityBodySchema = t.type({ + assistantId: t.string, +}); + +async function handler( + req: NextApiRequest, + res: NextApiResponse< + PostMemberAssistantVisibilityResponseBody | { success: boolean } + > +): Promise { + const session = await getSession(req, res); + const auth = await Authenticator.fromSession( + session, + req.query.wId as string + ); + + const owner = auth.workspace(); + if (!owner) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "workspace_not_found", + message: "The workspace was not found.", + }, + }); + } + + const user = auth.user(); + if (!user) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "workspace_user_not_found", + message: "The user requested was not found.", + }, + }); + } + + switch (req.method) { + case "POST": + const bodyValidation = + PostMemberAssistantVisibilityRequestBodySchema.decode(req.body); + if (isLeft(bodyValidation)) { + const pathError = reporter.formatValidationErrors(bodyValidation.left); + + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: `Invalid request body: ${pathError}`, + }, + }); + } + + const { assistantId, visibility } = bodyValidation.right; + const agentConfiguration = await getAgentConfiguration(auth, assistantId); + if (!agentConfiguration) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "agent_configuration_not_found", + message: "The agent requested was not found.", + }, + }); + } + const [userId, workspaceId] = [auth.user()?.id, auth.workspace()?.id]; + const membership = await Membership.findOne({ + where: { userId, workspaceId }, + }); + if (!membership) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "membership_not_found", + message: "The membership requested was not found.", + }, + }); + } + const result = await setVisibilityOverrideForUser({ + auth, + agentId: assistantId, + visibility, + }); + if (result.isErr()) { + return apiError(req, res, { + status_code: 500, + api_error: { + type: "internal_server_error", + message: result.error.message, + }, + }); + } + res.status(200).json(result.value); + return; + case "DELETE": + const bodyValidationDelete = + DeleteMemberAssistantsVisibilityBodySchema.decode(req.body); + if (isLeft(bodyValidationDelete)) { + const pathError = reporter.formatValidationErrors( + bodyValidationDelete.left + ); + + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: `Invalid request body: ${pathError}`, + }, + }); + } + const { assistantId: assistantIdToDelete } = bodyValidationDelete.right; + const deleteAgentConfiguration = await getAgentConfiguration( + auth, + assistantIdToDelete + ); + if (!deleteAgentConfiguration) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "agent_configuration_not_found", + message: "The agent requested was not found.", + }, + }); + } + const delMembership = await Membership.findOne({ + where: { userId: auth.user()?.id, workspaceId: auth.workspace()?.id }, + }); + if (!delMembership) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "membership_not_found", + message: "The membership requested was not found.", + }, + }); + } + + const deleteResult = await deleteVisibilityOverrideForUser({ + auth, + agentId: assistantIdToDelete, + }); + if (deleteResult.isErr()) { + return apiError(req, res, { + status_code: 500, + api_error: { + type: "internal_server_error", + message: deleteResult.error.message, + }, + }); + } + res.status(200).json(deleteResult.value); + return; + default: + return apiError(req, res, { + status_code: 405, + api_error: { + type: "method_not_supported_error", + message: "The method passed is not supported, POST is expected.", + }, + }); + } +} + +export default withLogging(handler); diff --git a/front/types/assistant/agent.ts b/front/types/assistant/agent.ts index 380b92a48374..fd26a20adb82 100644 --- a/front/types/assistant/agent.ts +++ b/front/types/assistant/agent.ts @@ -73,6 +73,13 @@ export type AgentConfigurationScope = | "published" | "private"; +/* By default, agents with scope 'workspace' are in users' assistants list, whereeas agents with + * scope 'published' aren't. But a user can override the default behaviour, as per the type below */ + +export type AgentVisibilityOverrideType = + | "workspace-unlisted" + | "published-listed"; + export type AgentConfigurationType = { id: ModelId;