diff --git a/front/admin/cli.ts b/front/admin/cli.ts index 45fa6ec496abd..a9af39605acea 100644 --- a/front/admin/cli.ts +++ b/front/admin/cli.ts @@ -13,10 +13,8 @@ import { getConversation } from "@app/lib/api/assistant/conversation"; import { renderConversationForModelMultiActions } from "@app/lib/api/assistant/generation"; import config from "@app/lib/api/config"; import { getDataSources } from "@app/lib/api/data_sources"; -import { renderUserType } from "@app/lib/api/user"; import { Authenticator } from "@app/lib/auth"; import { DataSource } from "@app/lib/models/data_source"; -import { User } from "@app/lib/models/user"; import { Workspace } from "@app/lib/models/workspace"; import { FREE_UPGRADED_PLAN_CODE } from "@app/lib/plans/plan_codes"; import { @@ -25,6 +23,7 @@ import { } from "@app/lib/plans/subscription"; import { MembershipResource } from "@app/lib/resources/membership_resource"; import { generateLegacyModelSId } from "@app/lib/resources/string_ids"; +import { UserResource } from "@app/lib/resources/user_resource"; import logger from "@app/logger/logger"; // `cli` takes an object type and a command as first two arguments and then a list of arguments. @@ -183,11 +182,7 @@ const user = async (command: string, args: parseArgs.ParsedArgs) => { throw new Error("Missing --username argument"); } - const users = await User.findAll({ - where: { - username: args.username, - }, - }); + const users = await UserResource.listByUsername(args.username); users.forEach((u) => { console.log( @@ -201,12 +196,7 @@ const user = async (command: string, args: parseArgs.ParsedArgs) => { throw new Error("Missing --userId argument"); } - const u = await User.findOne({ - where: { - id: args.userId, - }, - }); - + const u = await UserResource.fetchByModelId(args.userId); if (!u) { throw new Error(`User not found: userId='${args.userId}'`); } @@ -218,7 +208,7 @@ const user = async (command: string, args: parseArgs.ParsedArgs) => { console.log(` email: ${u.email}`); const memberships = await MembershipResource.getLatestMemberships({ - users: [renderUserType(u)], + users: [u], }); const workspaces = await Workspace.findAll({ diff --git a/front/lib/api/assistant/conversation.ts b/front/lib/api/assistant/conversation.ts index 3f0eac13d6aa7..ab4c5248efb31 100644 --- a/front/lib/api/assistant/conversation.ts +++ b/front/lib/api/assistant/conversation.ts @@ -70,12 +70,12 @@ import { Message, UserMessage, } from "@app/lib/models/assistant/conversation"; -import { User } from "@app/lib/models/user"; import { countActiveSeatsInWorkspaceCached } from "@app/lib/plans/usage/seats"; import { ContentFragmentResource } from "@app/lib/resources/content_fragment_resource"; import { frontSequelize } from "@app/lib/resources/storage"; import { ContentFragmentModel } from "@app/lib/resources/storage/models/content_fragment"; import { generateLegacyModelSId } from "@app/lib/resources/string_ids"; +import { UserResource } from "@app/lib/resources/user_resource"; import { ServerSideTracking } from "@app/lib/tracking/server"; import logger from "@app/logger/logger"; import { launchUpdateUsageWorkflow } from "@app/temporal/usage_queue/client"; @@ -751,10 +751,10 @@ export async function* postUserMessage( type: "user_message", visibility: "visible", version: 0, - user: user, - mentions: mentions, + user, + mentions, content, - context: context, + context, rank: m.rank, }; @@ -960,11 +960,7 @@ export async function* postUserMessage( async function logIfUserUnknown() { try { if (!user && context.email) { - const macthingUser = await User.findOne({ - where: { - email: context.email, - }, - }); + const macthingUser = await UserResource.fetchByEmail(context.email); if (!macthingUser) { logger.warn( @@ -1214,7 +1210,7 @@ export async function* editUserMessage( type: "user_message", visibility: m.visibility, version: m.version, - user: user, + user, mentions, content, context: message.context, diff --git a/front/lib/api/assistant/messages.ts b/front/lib/api/assistant/messages.ts index 622dfae334c2d..a7509c1410f9a 100644 --- a/front/lib/api/assistant/messages.ts +++ b/front/lib/api/assistant/messages.ts @@ -32,9 +32,9 @@ import { Message, UserMessage, } from "@app/lib/models/assistant/conversation"; -import { User } from "@app/lib/models/user"; import { ContentFragmentResource } from "@app/lib/resources/content_fragment_resource"; import { ContentFragmentModel } from "@app/lib/resources/storage/models/content_fragment"; +import { UserResource } from "@app/lib/resources/user_resource"; import { processActionTypesFromAgentMessageIds } from "./actions/process"; import { retrievalActionTypesFromAgentMessageIds } from "./actions/retrieval"; @@ -58,11 +58,7 @@ export async function batchRenderUserMessages( if (userIds.length === 0) { return []; } - return User.findAll({ - where: { - id: userIds, - }, - }); + return UserResource.listByModelIds(userIds); })(), ]); @@ -83,21 +79,7 @@ export async function batchRenderUserMessages( visibility: message.visibility, version: message.version, created: message.createdAt.getTime(), - user: user - ? { - sId: user.sId, - id: user.id, - createdAt: user.createdAt.getTime(), - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - fullName: - user.firstName + (user.lastName ? ` ${user.lastName}` : ""), - provider: user.provider, - image: user.imageUrl, - } - : null, + user: user ? user.toJSON() : null, mentions: messageMentions.map((m) => { if (m.agentConfigurationId) { return { diff --git a/front/lib/api/assistant/recent_authors.ts b/front/lib/api/assistant/recent_authors.ts index f3abc463a99bf..2e2f398f869d3 100644 --- a/front/lib/api/assistant/recent_authors.ts +++ b/front/lib/api/assistant/recent_authors.ts @@ -7,11 +7,10 @@ import { removeNulls } from "@dust-tt/types"; import { Sequelize } from "sequelize"; import { runOnRedis } from "@app/lib/api/redis"; -import { renderUserType } from "@app/lib/api/user"; import { getGlobalAgentAuthorName } from "@app/lib/assistant"; import type { Authenticator } from "@app/lib/auth"; import { AgentConfiguration } from "@app/lib/models/assistant/agent"; -import { User } from "@app/lib/models/user"; +import { UserResource } from "@app/lib/resources/user_resource"; // We keep the most recent authorIds for 3 days. const recentAuthorIdsKeyTTL = 60 * 60 * 24 * 3; // 3 days. @@ -172,15 +171,13 @@ export async function getAgentsRecentAuthors({ ); const authorByUserId: Record = ( - await User.findAll({ - where: { - id: removeNulls( - Array.from(new Set(Object.values(recentAuthorsIdsByAgentId).flat())) - ), - }, - }) + await UserResource.listByModelIds( + removeNulls( + Array.from(new Set(Object.values(recentAuthorsIdsByAgentId).flat())) + ) + ) ).reduce>((acc, user) => { - acc[user.id] = renderUserType(user); + acc[user.id] = user.toJSON(); return acc; }, {}); diff --git a/front/lib/api/user.ts b/front/lib/api/user.ts index 975a89e4f302b..376954239e947 100644 --- a/front/lib/api/user.ts +++ b/front/lib/api/user.ts @@ -1,25 +1,19 @@ -import type { UserMetadataType, UserType } from "@dust-tt/types"; +import type { + Result, + UserMetadataType, + UserType, + UserTypeWithWorkspaces, +} from "@dust-tt/types"; +import { Err, Ok } from "@dust-tt/types"; import type { Authenticator } from "@app/lib/auth"; -import { User, UserMetadata } from "@app/lib/models/user"; +import { UserMetadata } from "@app/lib/models/user"; +import { Workspace } from "@app/lib/models/workspace"; +import { UserResource } from "@app/lib/resources/user_resource"; +import logger from "@app/logger/logger"; import { MembershipResource } from "../resources/membership_resource"; -export function renderUserType(user: User): UserType { - return { - sId: user.sId, - id: user.id, - createdAt: user.createdAt.getTime(), - provider: user.provider, - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - fullName: user.firstName + (user.lastName ? ` ${user.lastName}` : ""), - image: user.imageUrl, - }; -} - /** * This function checks that the user had at least one membership in the past for this workspace * otherwise returns null, preventing retrieving user information from their sId. @@ -27,17 +21,13 @@ export function renderUserType(user: User): UserType { export async function getUserForWorkspace( auth: Authenticator, { userId }: { userId: string } -): Promise { +): Promise { const owner = auth.workspace(); if (!owner || !(auth.isAdmin() || auth.user()?.sId === userId)) { return null; } - const user = await User.findOne({ - where: { - sId: userId, - }, - }); + const user = await UserResource.fetchById(userId); if (!user) { return null; @@ -45,7 +35,7 @@ export async function getUserForWorkspace( const membership = await MembershipResource.getLatestMembershipOfUserInWorkspace({ - user: renderUserType(user), + user, workspace: owner, }); @@ -53,15 +43,7 @@ export async function getUserForWorkspace( return null; } - return renderUserType(user); -} - -export async function deleteUser(user: UserType): Promise { - await User.destroy({ - where: { - id: user.id, - }, - }); + return user; } /** @@ -121,44 +103,37 @@ export async function setUserMetadata( await metadata.save(); } -export async function updateUserFullName({ - user, - firstName, - lastName, -}: { - user: UserType; - firstName: string; - lastName: string; -}): Promise { - const u = await User.findOne({ - where: { - id: user.id, - }, - }); +export async function fetchRevokedWorkspace( + user: UserTypeWithWorkspaces +): Promise> { + // TODO(@fontanierh): this doesn't look very solid as it will start to behave + // weirdly if a user has multiple revoked memberships. + const u = await UserResource.fetchByModelId(user.id); if (!u) { - return null; + const message = "Unreachable: user not found."; + logger.error({ userId: user.id }, message); + return new Err(new Error(message)); } - u.firstName = firstName; - u.lastName = lastName; - u.name = `${firstName} ${lastName}`; - await u.save(); + const memberships = await MembershipResource.getLatestMemberships({ + users: [u], + }); - return true; -} + if (!memberships.length) { + const message = "Unreachable: user has no memberships."; + logger.error({ userId: user.id }, message); + return new Err(new Error(message)); + } + + const revokedWorkspaceId = memberships[0].workspaceId; + const workspace = await Workspace.findByPk(revokedWorkspaceId); -export async function unsafeGetUsersByModelId( - modelIds: number[] -): Promise { - if (modelIds.length === 0) { - return []; + if (!workspace) { + const message = "Unreachable: workspace not found."; + logger.error({ userId: user.id, workspaceId: revokedWorkspaceId }, message); + return new Err(new Error(message)); } - const users = await User.findAll({ - where: { - id: modelIds, - }, - }); - return users.map((u) => renderUserType(u)); + return new Ok(workspace); } diff --git a/front/lib/api/workspace.ts b/front/lib/api/workspace.ts index e70a9b8b67084..223f8333f14ba 100644 --- a/front/lib/api/workspace.ts +++ b/front/lib/api/workspace.ts @@ -10,9 +10,9 @@ import type { } from "@dust-tt/types"; import type { Authenticator } from "@app/lib/auth"; -import { User } from "@app/lib/models/user"; import { Workspace, WorkspaceHasDomain } from "@app/lib/models/workspace"; import { MembershipResource } from "@app/lib/resources/membership_resource"; +import { UserResource } from "@app/lib/resources/user_resource"; import { renderLightWorkspaceType } from "@app/lib/workspace"; export async function getWorkspaceInfos( @@ -144,11 +144,9 @@ export async function getMembers( roles, }); - const users = await User.findAll({ - where: { - id: memberships.map((m) => m.userId), - }, - }); + const users = await UserResource.listByModelIds( + memberships.map((m) => m.userId) + ); return users.map((u) => { const m = memberships.find((m) => m.userId === u.id); @@ -166,16 +164,7 @@ export async function getMembers( } return { - sId: u.sId, - id: u.id, - createdAt: u.createdAt.getTime(), - provider: u.provider, - username: u.username, - email: u.email, - fullName: u.firstName + (u.lastName ? ` ${u.lastName}` : ""), - firstName: u.firstName, - lastName: u.lastName, - image: u.imageUrl, + ...u.toJSON(), workspaces: [{ ...owner, role, flags: null }], }; }); diff --git a/front/lib/auth.ts b/front/lib/auth.ts index 96eac6bcf9202..16181abde9006 100644 --- a/front/lib/auth.ts +++ b/front/lib/auth.ts @@ -26,12 +26,10 @@ import type { NextApiResponse, } from "next"; -import { renderUserType } from "@app/lib/api/user"; import type { SessionWithUser } from "@app/lib/iam/provider"; import { isValidSession } from "@app/lib/iam/provider"; import { FeatureFlag } from "@app/lib/models/feature_flag"; import { Plan, Subscription } from "@app/lib/models/plan"; -import { User } from "@app/lib/models/user"; import { Workspace } from "@app/lib/models/workspace"; import type { PlanAttributes } from "@app/lib/plans/free_plans"; import { FREE_NO_PLAN_DATA } from "@app/lib/plans/free_plans"; @@ -41,6 +39,7 @@ import { getTrialVersionForPlan, isTrial } from "@app/lib/plans/trial"; import type { KeyAuthType } from "@app/lib/resources/key_resource"; import { KeyResource } from "@app/lib/resources/key_resource"; import { MembershipResource } from "@app/lib/resources/membership_resource"; +import { UserResource } from "@app/lib/resources/user_resource"; import { renderLightWorkspaceType } from "@app/lib/workspace"; import logger from "@app/logger/logger"; const { @@ -65,7 +64,7 @@ export class Authenticator { _key?: KeyAuthType; _role: RoleType; _subscription: SubscriptionType | null; - _user: User | null; + _user: UserResource | null; _workspace: Workspace | null; // Should only be called from the static methods below. @@ -78,7 +77,7 @@ export class Authenticator { key, }: { workspace?: Workspace | null; - user?: User | null; + user?: UserResource | null; role: RoleType; subscription?: SubscriptionType | null; flags: WhitelistableFeature[]; @@ -116,11 +115,7 @@ export class Authenticator { if (!session) { return null; } else { - return User.findOne({ - where: { - auth0Sub: session.user.sub, - }, - }); + return UserResource.fetchByAuth0Sub(session.user.sub); } })(), ]); @@ -132,7 +127,7 @@ export class Authenticator { if (user && workspace) { [role, subscription, flags] = await Promise.all([ MembershipResource.getActiveMembershipOfUserInWorkspace({ - user: renderUserType(user), + user, workspace: renderLightWorkspaceType({ workspace }), }).then((m) => m?.role ?? "none"), subscriptionForWorkspace(renderLightWorkspaceType({ workspace })), @@ -181,11 +176,7 @@ export class Authenticator { if (!session) { return null; } else { - return User.findOne({ - where: { - auth0Sub: session.user.sub, - }, - }); + return UserResource.fetchByAuth0Sub(session.user.sub); } })(), ]); @@ -386,11 +377,7 @@ export class Authenticator { throw new Error("Workspace not found."); } - const user = await User.findOne({ - where: { - email: userEmail, - }, - }); + const user = await UserResource.fetchByEmail(userEmail); // If the user does not exist (e.g., whitelisted email addresses), // simply ignore and return null. if (!user) { @@ -400,7 +387,7 @@ export class Authenticator { // Verify that the user has an active membership in the specified workspace. const activeMembership = await MembershipResource.getActiveMembershipOfUserInWorkspace({ - user: renderUserType(user), + user, workspace: owner, }); // If the user does not have an active membership in the workspace, @@ -489,7 +476,7 @@ export class Authenticator { * @returns */ user(): UserType | null { - return this._user ? renderUserType(this._user) : null; + return this._user ? this._user.toJSON() : null; } getNonNullableUser(): UserType { diff --git a/front/lib/iam/invitations.ts b/front/lib/iam/invitations.ts index 1c4067c290bc6..de5d18a470c85 100644 --- a/front/lib/iam/invitations.ts +++ b/front/lib/iam/invitations.ts @@ -2,7 +2,6 @@ import type { LightWorkspaceType, MembershipInvitationType, Result, - UserType, } from "@dust-tt/types"; import { Err, Ok } from "@dust-tt/types"; import { verify } from "jsonwebtoken"; @@ -10,6 +9,7 @@ import { verify } from "jsonwebtoken"; import config from "@app/lib/api/config"; import { AuthFlowError } from "@app/lib/iam/errors"; import { MembershipInvitation, Workspace } from "@app/lib/models/workspace"; +import type { UserResource } from "@app/lib/resources/user_resource"; import { renderLightWorkspaceType } from "@app/lib/workspace"; import logger from "@app/logger/logger"; @@ -109,7 +109,7 @@ export async function getPendingMembershipInvitationWithWorkspaceForEmail( export async function markInvitationAsConsumed( membershipInvite: MembershipInvitation, - user: UserType + user: UserResource ) { membershipInvite.status = "consumed"; membershipInvite.invitedUserId = user.id; diff --git a/front/lib/iam/session.ts b/front/lib/iam/session.ts index 88b30065bf6af..864d020f7a494 100644 --- a/front/lib/iam/session.ts +++ b/front/lib/iam/session.ts @@ -6,7 +6,6 @@ import type { } from "next"; import type { ParsedUrlQuery } from "querystring"; -import { renderUserType } from "@app/lib/api/user"; import { Authenticator, getSession } from "@app/lib/auth"; import { isEnterpriseConnection } from "@app/lib/iam/enterprise"; import type { SessionWithUser } from "@app/lib/iam/provider"; @@ -38,7 +37,7 @@ export async function getUserFromSession( } const memberships = await MembershipResource.getActiveMemberships({ - users: [renderUserType(user)], + users: [user], }); const workspaces = await Workspace.findAll({ where: { @@ -49,16 +48,7 @@ export async function getUserFromSession( await maybeUpdateFromExternalUser(user, session.user); return { - sId: user.sId, - id: user.id, - createdAt: user.createdAt.getTime(), - provider: user.provider, - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - fullName: user.firstName + (user.lastName ? ` ${user.lastName}` : ""), - image: user.imageUrl, + ...user.toJSON(), workspaces: workspaces.map((w) => { const m = memberships.find((m) => m.workspaceId === w.id); let role = "none" as RoleType; diff --git a/front/lib/iam/users.ts b/front/lib/iam/users.ts index 85e4c0e6414aa..a929d24c08382 100644 --- a/front/lib/iam/users.ts +++ b/front/lib/iam/users.ts @@ -1,11 +1,11 @@ import type { Session } from "@auth0/nextjs-auth0"; -import type { UserProviderType, UserType } from "@dust-tt/types"; +import type { UserProviderType } from "@dust-tt/types"; import { sanitizeString } from "@dust-tt/types"; -import { renderUserType } from "@app/lib/api/user"; import type { ExternalUser, SessionWithUser } from "@app/lib/iam/provider"; import { User } from "@app/lib/models/user"; import { generateLegacyModelSId } from "@app/lib/resources/string_ids"; +import { UserResource } from "@app/lib/resources/user_resource"; import { ServerSideTracking } from "@app/lib/tracking/server"; import { guessFirstAndLastNameFromFullName } from "@app/lib/user"; @@ -18,27 +18,21 @@ async function fetchUserWithLegacyProvider( { provider, providerId }: LegacyProviderInfo, sub: string ) { - const user = await User.findOne({ - where: { - provider, - providerId: providerId.toString(), - }, - }); + const user = await UserResource.fetchByProvider( + provider, + providerId.toString() + ); // If a legacy user is found, attach the Auth0 user ID (sub) to the existing user account. if (user) { - await user.update({ auth0Sub: sub }); + await user.updateAuth0Sub(sub); } return user; } async function fetchUserWithAuth0Sub(sub: string) { - const userWithAuth0 = await User.findOne({ - where: { - auth0Sub: sub, - }, - }); + const userWithAuth0 = await UserResource.fetchByAuth0Sub(sub); return userWithAuth0; } @@ -76,7 +70,7 @@ export async function fetchUserFromSession(session: SessionWithUser) { } export async function maybeUpdateFromExternalUser( - user: User, + user: UserResource, externalUser: ExternalUser ) { if (externalUser.picture && externalUser.picture !== user.imageUrl) { @@ -95,46 +89,60 @@ export async function maybeUpdateFromExternalUser( export async function createOrUpdateUser( session: SessionWithUser -): Promise<{ user: UserType; created: boolean }> { +): Promise<{ user: UserResource; created: boolean }> { const { user: externalUser } = session; const user = await fetchUserFromSession(session); if (user) { + const updateArgs: { [key: string]: string } = {}; + // We only update the user's email if the email is verified. if (externalUser.email_verified) { - user.email = externalUser.email; + updateArgs.email = externalUser.email; } // Update the user object from the updated session information. - user.username = externalUser.nickname; - user.name = externalUser.name; + updateArgs.username = externalUser.nickname; if (!user.firstName && !user.lastName) { if (externalUser.given_name && externalUser.family_name) { - user.firstName = externalUser.given_name; - user.lastName = externalUser.family_name; + updateArgs.firstName = externalUser.given_name; + updateArgs.lastName = externalUser.family_name; } else { const { firstName, lastName } = guessFirstAndLastNameFromFullName( externalUser.name ); - user.firstName = firstName; - user.lastName = lastName; + updateArgs.firstName = firstName; + updateArgs.lastName = lastName || ""; } } - await user.save(); + if (Object.keys(updateArgs).length > 0) { + const needsUpdate = Object.entries(updateArgs).some( + ([key, value]) => user[key as keyof typeof user] !== value + ); + + if (needsUpdate) { + await user.updateInfo( + updateArgs.username || user.name, + updateArgs.firstName || user.firstName, + updateArgs.lastName || user.lastName, + updateArgs.email || user.email + ); + } + } - return { user: renderUserType(user), created: false }; + return { user, created: false }; } else { const { firstName, lastName } = guessFirstAndLastNameFromFullName( externalUser.name ); - const u = await User.create({ + const u = await UserResource.makeNew({ sId: generateLegacyModelSId(), auth0Sub: externalUser.sub, - provider: mapAuth0ProviderToLegacy(session)?.provider, + provider: mapAuth0ProviderToLegacy(session)?.provider ?? null, username: externalUser.nickname, email: sanitizeString(externalUser.email), name: externalUser.name, @@ -157,6 +165,6 @@ export async function createOrUpdateUser( }, }); - return { user: renderUserType(u), created: true }; + return { user: u, created: true }; } } diff --git a/front/lib/resources/labs_transcripts_resource.ts b/front/lib/resources/labs_transcripts_resource.ts index 984367956e8aa..f2b309884ccbb 100644 --- a/front/lib/resources/labs_transcripts_resource.ts +++ b/front/lib/resources/labs_transcripts_resource.ts @@ -11,11 +11,11 @@ import type { CreationAttributes } from "sequelize"; import type { Authenticator } from "@app/lib/auth"; import config from "@app/lib/labs/config"; import { nangoDeleteConnection } from "@app/lib/labs/transcripts/utils/helpers"; -import { User } from "@app/lib/models/user"; import { BaseResource } from "@app/lib/resources/base_resource"; import { LabsTranscriptsConfigurationModel } from "@app/lib/resources/storage/models/labs_transcripts"; import { LabsTranscriptsHistoryModel } from "@app/lib/resources/storage/models/labs_transcripts"; import type { ReadonlyAttributesType } from "@app/lib/resources/storage/types"; +import { UserResource } from "@app/lib/resources/user_resource"; // Attributes are marked as read-only to reflect the stateless nature of our Resource. // This design will be moved up to BaseResource once we transition away from Sequelize. @@ -118,8 +118,8 @@ export class LabsTranscriptsConfigurationResource extends BaseResource { - return User.findByPk(this.userId); + async getUser(): Promise { + return UserResource.fetchByModelId(this.userId); } async setAgentConfigurationId({ diff --git a/front/lib/resources/membership_resource.ts b/front/lib/resources/membership_resource.ts index f8776994ff59f..a1b93e14179d0 100644 --- a/front/lib/resources/membership_resource.ts +++ b/front/lib/resources/membership_resource.ts @@ -3,7 +3,6 @@ import type { MembershipRoleType, RequireAtLeastOne, Result, - UserType, } from "@dust-tt/types"; import { Err, Ok } from "@dust-tt/types"; import type { @@ -19,11 +18,12 @@ import type { Authenticator } from "@app/lib/auth"; import { BaseResource } from "@app/lib/resources/base_resource"; import { MembershipModel } from "@app/lib/resources/storage/models/membership"; import type { ReadonlyAttributesType } from "@app/lib/resources/storage/types"; +import type { UserResource } from "@app/lib/resources/user_resource"; import { ServerSideTracking } from "@app/lib/tracking/server"; import logger from "@app/logger/logger"; type GetMembershipsOptions = RequireAtLeastOne<{ - users: UserType[]; + users: UserResource[]; workspace: LightWorkspaceType; }> & { roles?: MembershipRoleType[]; @@ -146,7 +146,7 @@ export class MembershipResource extends BaseResource { workspace, transaction, }: { - user: UserType; + user: UserResource; workspace: LightWorkspaceType; transaction?: Transaction; }): Promise { @@ -180,7 +180,7 @@ export class MembershipResource extends BaseResource { workspace, transaction, }: { - user: UserType; + user: UserResource; workspace: LightWorkspaceType; transaction?: Transaction; }): Promise { @@ -246,7 +246,7 @@ export class MembershipResource extends BaseResource { startAt = new Date(), transaction, }: { - user: UserType; + user: UserResource; workspace: LightWorkspaceType; role: MembershipRoleType; startAt?: Date; @@ -282,7 +282,7 @@ export class MembershipResource extends BaseResource { ); void ServerSideTracking.trackCreateMembership({ - user, + user: user.toJSON(), workspace, role: newMembership.role, startAt: newMembership.startAt, @@ -297,7 +297,7 @@ export class MembershipResource extends BaseResource { endAt = new Date(), transaction, }: { - user: UserType; + user: UserResource; workspace: LightWorkspaceType; endAt?: Date; transaction?: Transaction; @@ -329,7 +329,7 @@ export class MembershipResource extends BaseResource { ); void ServerSideTracking.trackRevokeMembership({ - user, + user: user.toJSON(), workspace, role: membership.role, startAt: membership.startAt, @@ -346,7 +346,7 @@ export class MembershipResource extends BaseResource { allowTerminated = false, transaction, }: { - user: UserType; + user: UserResource; workspace: LightWorkspaceType; newRole: Exclude; // If true, allow updating the role of a terminated membership (which will also un-terminate it). @@ -397,7 +397,7 @@ export class MembershipResource extends BaseResource { } void ServerSideTracking.trackUpdateMembershipRole({ - user, + user: user.toJSON(), workspace, previousRole: membership.role, role: newRole, diff --git a/front/lib/resources/user_resource.ts b/front/lib/resources/user_resource.ts new file mode 100644 index 0000000000000..9f5d0bb223d8a --- /dev/null +++ b/front/lib/resources/user_resource.ts @@ -0,0 +1,245 @@ +import type { + ModelId, + Result, + UserProviderType, + UserType, +} from "@dust-tt/types"; +import { Err, Ok } from "@dust-tt/types"; +import type { Attributes, ModelStatic, Transaction } from "sequelize"; + +import type { Authenticator } from "@app/lib/auth"; +import { User } from "@app/lib/models/user"; +import { BaseResource } from "@app/lib/resources/base_resource"; +import { MembershipModel } from "@app/lib/resources/storage/models/membership"; +import type { ReadonlyAttributesType } from "@app/lib/resources/storage/types"; + +// Attributes are marked as read-only to reflect the stateless nature of our Resource. +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-unsafe-declaration-merging +export interface UserResource extends ReadonlyAttributesType {} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class UserResource extends BaseResource { + static model: ModelStatic = User; + + constructor(model: ModelStatic, blob: Attributes) { + super(User, blob); + } + + static async makeNew( + blob: Omit< + Attributes, + | "id" + | "createdAt" + | "updatedAt" + | "isDustSuperUser" + | "providerId" + | "imageUrl" + > & + Partial, "providerId" | "imageUrl">> + ): Promise { + const user = await User.create(blob); + return new this(User, user.get()); + } + + static async listByModelIds(ids: ModelId[]): Promise { + const users = await User.findAll({ + where: { + id: ids, + }, + }); + + return users.map((user) => new UserResource(User, user.get())); + } + + static async listByUsername(username: string): Promise { + const users = await User.findAll({ + where: { + username, + }, + }); + + return users.map((user) => new UserResource(User, user.get())); + } + + static async listByEmail(email: string): Promise { + const users = await User.findAll({ + where: { + email, + }, + }); + + return users.map((user) => new UserResource(User, user.get())); + } + + static async fetchById(userId: string): Promise { + const user = await User.findOne({ + where: { + sId: userId, + }, + }); + return user ? new UserResource(User, user.get()) : null; + } + + static async fetchByAuth0Sub(sub: string): Promise { + const user = await User.findOne({ + where: { + auth0Sub: sub, + }, + }); + return user ? new UserResource(User, user.get()) : null; + } + + static async fetchByEmail(email: string): Promise { + const user = await User.findOne({ + where: { + email, + }, + }); + + return user ? new UserResource(User, user.get()) : null; + } + + static async fetchByProvider( + provider: UserProviderType, + providerId: string + ): Promise { + const user = await User.findOne({ + where: { + provider, + providerId, + }, + }); + + return user ? new UserResource(User, user.get()) : null; + } + + static async getWorkspaceFirstAdmin( + workspaceId: number + ): Promise { + const user = await User.findOne({ + include: [ + { + model: MembershipModel, + where: { + role: "admin", + workspaceId, + }, + required: true, + }, + ], + order: [["createdAt", "ASC"]], + }); + + return user ? new UserResource(User, user.get()) : null; + } + + async updateAuth0Sub(sub: string): Promise { + await this.model.update( + { + auth0Sub: sub, + }, + { + where: { + id: this.id, + }, + } + ); + } + + async updateName( + firstName: string, + lastName: string | null + ): Promise> { + try { + await this.model.update( + { + firstName, + lastName, + }, + { + where: { + id: this.id, + }, + returning: true, + } + ); + + return new Ok(undefined); + } catch (err) { + return new Err(err as Error); + } + } + + async updateInfo( + username: string, + firstName: string, + lastName: string | null, + email: string + ): Promise { + const [, affectedRows] = await this.model.update( + { username, firstName, lastName, email }, + { + where: { + id: this.id, + }, + returning: true, + } + ); + + Object.assign(this, affectedRows[0].get()); + } + + async delete( + auth: Authenticator, + transaction?: Transaction + ): Promise> { + try { + await this.model.destroy({ + where: { + id: this.id, + }, + transaction, + }); + + return new Ok(undefined); + } catch (err) { + return new Err(err as Error); + } + } + + async unsafeDelete( + transaction?: Transaction + ): Promise> { + try { + await this.model.destroy({ + where: { + id: this.id, + }, + transaction, + }); + + return new Ok(undefined); + } catch (err) { + return new Err(err as Error); + } + } + + fullName(): string { + return [this.firstName, this.lastName].filter(Boolean).join(" "); + } + + toJSON(): UserType { + return { + sId: this.sId, + id: this.id, + createdAt: this.createdAt.getTime(), + provider: this.provider, + username: this.username, + email: this.email, + firstName: this.firstName, + lastName: this.lastName, + fullName: this.fullName(), + image: this.imageUrl, + }; + } +} diff --git a/front/lib/tracking/customerio/server.ts b/front/lib/tracking/customerio/server.ts index c5b3a8a26ca2e..ce3138e4f02f0 100644 --- a/front/lib/tracking/customerio/server.ts +++ b/front/lib/tracking/customerio/server.ts @@ -11,6 +11,7 @@ import { subscriptionForWorkspace } from "@app/lib/auth"; import { Workspace } from "@app/lib/models/workspace"; import { countActiveSeatsInWorkspaceCached } from "@app/lib/plans/usage/seats"; import { MembershipResource } from "@app/lib/resources/membership_resource"; +import { UserResource } from "@app/lib/resources/user_resource"; import { renderLightWorkspaceType } from "@app/lib/workspace"; import logger from "@app/logger/logger"; @@ -130,8 +131,18 @@ export class CustomerioServerSideTracking { } static async backfillUser({ user }: { user: UserType }) { + const u = await UserResource.fetchById(user.sId); + + if (!u) { + logger.error( + { userId: user.sId }, + "Failed to backfill user on Customer.io" + ); + return; + } + const userMemberships = await MembershipResource.getLatestMemberships({ - users: [user], + users: [u], }); const workspaces = _.keyBy( diff --git a/front/lib/workspace.ts b/front/lib/workspace.ts index 77923246c7e6f..aba39ed32c526 100644 --- a/front/lib/workspace.ts +++ b/front/lib/workspace.ts @@ -1,12 +1,12 @@ import type { LightWorkspaceType, RoleType, + UserType, WorkspaceType, } from "@dust-tt/types"; -import { User } from "@app/lib/models/user"; import type { Workspace } from "@app/lib/models/workspace"; -import { MembershipModel } from "@app/lib/resources/storage/models/membership"; +import { UserResource } from "@app/lib/resources/user_resource"; export function renderLightWorkspaceType({ workspace, @@ -27,18 +27,9 @@ export function renderLightWorkspaceType({ } // TODO: This belong to the WorkspaceResource. -export async function getWorkspaceFirstAdmin(workspace: Workspace) { - return User.findOne({ - include: [ - { - model: MembershipModel, - where: { - role: "admin", - workspaceId: workspace.id, - }, - required: true, - }, - ], - order: [["createdAt", "ASC"]], - }); +export async function getWorkspaceFirstAdmin( + workspace: Workspace +): Promise { + const user = await UserResource.getWorkspaceFirstAdmin(workspace.id); + return user?.toJSON(); } diff --git a/front/migrations/20240423_backfill_customerio.ts b/front/migrations/20240423_backfill_customerio.ts index 73a6dc15cbb21..3c7d90af4d513 100644 --- a/front/migrations/20240423_backfill_customerio.ts +++ b/front/migrations/20240423_backfill_customerio.ts @@ -1,7 +1,7 @@ import * as _ from "lodash"; -import { renderUserType } from "@app/lib/api/user"; import { User } from "@app/lib/models/user"; +import { UserResource } from "@app/lib/resources/user_resource"; import { AmplitudeServerSideTracking } from "@app/lib/tracking/amplitude/server"; import { CustomerioServerSideTracking } from "@app/lib/tracking/customerio/server"; import logger from "@app/logger/logger"; @@ -9,7 +9,7 @@ import { makeScript } from "@app/scripts/helpers"; const backfillCustomerIo = async (execute: boolean) => { const allUserModels = await User.findAll(); - const users = allUserModels.map((u) => renderUserType(u)); + const users = allUserModels.map((u) => u); const chunks = _.chunk(users, 16); for (const [i, c] of chunks.entries()) { logger.info( @@ -20,19 +20,41 @@ const backfillCustomerIo = async (execute: boolean) => { if (execute) { await Promise.all( c.map((u) => - Promise.all([ - CustomerioServerSideTracking.backfillUser({ - user: u, - }).catch((err) => { + (async () => { + try { + const user = await UserResource.fetchByModelId(u.id); + if (!user) { + logger.error( + { userId: u.sId }, + "Failed to fetch userResource, skipping" + ); + return; + } + return await Promise.all([ + CustomerioServerSideTracking.backfillUser({ + user: user.toJSON(), + }).catch((err) => { + logger.error( + { userId: user.sId, err }, + "Failed to backfill user on Customer.io" + ); + }), + AmplitudeServerSideTracking._identifyUser({ + user: { + ...user.toJSON(), + fullName: `${user.firstName} ${user.lastName}`, + image: user.imageUrl, + createdAt: user.createdAt.getTime(), + }, + }), + ]); + } catch (err) { logger.error( { userId: u.sId, err }, - "Failed to backfill user on Customer.io" + "Failed to fetch userResource" ); - }), - // NOTE: this is unrelated to customerio, but leveraging this backfill - // to also identify all users on Amplitude. - AmplitudeServerSideTracking._identifyUser({ user: u }), - ]) + } + })() ) ); } diff --git a/front/migrations/20240513_backfill_customerio_delete_free_test.ts b/front/migrations/20240513_backfill_customerio_delete_free_test.ts index 4071b7b0f0617..0ead35d29f469 100644 --- a/front/migrations/20240513_backfill_customerio_delete_free_test.ts +++ b/front/migrations/20240513_backfill_customerio_delete_free_test.ts @@ -1,12 +1,12 @@ import { removeNulls } from "@dust-tt/types"; import * as _ from "lodash"; -import { renderUserType } from "@app/lib/api/user"; import { subscriptionForWorkspaces } from "@app/lib/auth"; import { User } from "@app/lib/models/user"; import { Workspace } from "@app/lib/models/workspace"; import { FREE_TEST_PLAN_CODE } from "@app/lib/plans/plan_codes"; import { MembershipResource } from "@app/lib/resources/membership_resource"; +import { UserResource } from "@app/lib/resources/user_resource"; import { CustomerioServerSideTracking } from "@app/lib/tracking/customerio/server"; import { renderLightWorkspaceType } from "@app/lib/workspace"; import logger from "@app/logger/logger"; @@ -14,7 +14,7 @@ import { makeScript } from "@app/scripts/helpers"; const backfillCustomerIo = async (execute: boolean) => { const allUserModels = await User.findAll(); - const users = allUserModels.map((u) => renderUserType(u)); + const users = allUserModels.map((u) => u); const chunks = _.chunk(users, 16); const deletedWorkspaceSids = new Set(); for (const [i, c] of chunks.entries()) { @@ -25,7 +25,7 @@ const backfillCustomerIo = async (execute: boolean) => { ); const membershipsByUserId = _.groupBy( await MembershipResource.getLatestMemberships({ - users: c, + users: c.map((u) => u.toJSON()), }), (m) => m.userId.toString() ); @@ -67,9 +67,17 @@ const backfillCustomerIo = async (execute: boolean) => { ); if (execute) { + const user = await UserResource.fetchByModelId(u.id); + if (!user) { + logger.error( + { userId: u.sId }, + "Failed to fetch userResource, skipping" + ); + continue; + } promises.push( CustomerioServerSideTracking.deleteUser({ - user: u, + user: user.toJSON(), }).catch((err) => { logger.error( { userId: u.sId, err }, diff --git a/front/migrations/20240513_backfill_customerio_membership_timestamps.ts b/front/migrations/20240513_backfill_customerio_membership_timestamps.ts index d537c42c91f7f..631617347b8bd 100644 --- a/front/migrations/20240513_backfill_customerio_membership_timestamps.ts +++ b/front/migrations/20240513_backfill_customerio_membership_timestamps.ts @@ -1,7 +1,6 @@ import { removeNulls } from "@dust-tt/types"; import * as _ from "lodash"; -import { unsafeGetUsersByModelId } from "@app/lib/api/user"; import { Plan, Subscription } from "@app/lib/models/plan"; import { Workspace } from "@app/lib/models/workspace"; import { @@ -9,6 +8,7 @@ import { FREE_TEST_PLAN_CODE, } from "@app/lib/plans/plan_codes"; import { MembershipResource } from "@app/lib/resources/membership_resource"; +import { UserResource } from "@app/lib/resources/user_resource"; import { CustomerioServerSideTracking } from "@app/lib/tracking/customerio/server"; import { renderLightWorkspaceType } from "@app/lib/workspace"; import logger from "@app/logger/logger"; @@ -63,7 +63,7 @@ const backfillCustomerIo = async (execute: boolean) => { workspace: renderLightWorkspaceType({ workspace }), }); const userIds = workspaceMemberships.map((m) => m.userId); - const users = await unsafeGetUsersByModelId(userIds); + const users = await UserResource.listByModelIds(userIds); logger.info( { workspaceId: workspace.sId, usersCount: users.length }, @@ -76,7 +76,9 @@ const backfillCustomerIo = async (execute: boolean) => { "Backfilling user..." ); if (execute) { - await CustomerioServerSideTracking.backfillUser({ user }); + await CustomerioServerSideTracking.backfillUser({ + user: user.toJSON(), + }); } } } diff --git a/front/pages/api/create-new-workspace.ts b/front/pages/api/create-new-workspace.ts index 2980b9e9608a0..28079e4a1ceb8 100644 --- a/front/pages/api/create-new-workspace.ts +++ b/front/pages/api/create-new-workspace.ts @@ -5,6 +5,7 @@ import { withSessionAuthentication } from "@app/lib/api/wrappers"; import { getSession } from "@app/lib/auth"; import { getUserFromSession } from "@app/lib/iam/session"; import { createWorkspace } from "@app/lib/iam/workspaces"; +import { UserResource } from "@app/lib/resources/user_resource"; import { apiError } from "@app/logger/withlogging"; import { createAndLogMembership } from "@app/pages/api/login"; @@ -51,8 +52,20 @@ async function handler( } const workspace = await createWorkspace(session); + const u = await UserResource.fetchByModelId(user.id); + + if (!u) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "user_not_found", + message: "The user was not found.", + }, + }); + } + await createAndLogMembership({ - user, + user: u, workspace, role: "admin", }); diff --git a/front/pages/api/login.ts b/front/pages/api/login.ts index eebff868a563a..3a10f0eefe54d 100644 --- a/front/pages/api/login.ts +++ b/front/pages/api/login.ts @@ -1,7 +1,6 @@ import type { ActiveRoleType, Result, - UserType, WithAPIErrorResponse, } from "@dust-tt/types"; import { Err, Ok } from "@dust-tt/types"; @@ -11,7 +10,6 @@ import { getMembershipInvitationToken, getMembershipInvitationUrlForToken, } from "@app/lib/api/invitation"; -import { deleteUser } from "@app/lib/api/user"; import { evaluateWorkspaceSeatAvailability } from "@app/lib/api/workspace"; import { getSession, subscriptionForWorkspace } from "@app/lib/auth"; import { AuthFlowError, SSOEnforcedError } from "@app/lib/iam/errors"; @@ -31,6 +29,7 @@ import { import type { MembershipInvitation } from "@app/lib/models/workspace"; import { Workspace } from "@app/lib/models/workspace"; import { MembershipResource } from "@app/lib/resources/membership_resource"; +import type { UserResource } from "@app/lib/resources/user_resource"; import { renderLightWorkspaceType } from "@app/lib/workspace"; import { apiError, withLogging } from "@app/logger/withlogging"; import { launchUpdateUsageWorkflow } from "@app/temporal/usage_queue/client"; @@ -39,7 +38,7 @@ import { launchUpdateUsageWorkflow } from "@app/temporal/usage_queue/client"; // all the checks (decoding the JWT) have been run before. Simply create the membership if // it does not already exist and mark the invitation as consumed. async function handleMembershipInvite( - user: UserType, + user: UserResource, membershipInvite: MembershipInvitation ): Promise< Result< @@ -133,7 +132,7 @@ function canJoinTargetWorkspace( } async function handleEnterpriseSignUpFlow( - user: UserType, + user: UserResource, enterpriseConnectionWorkspaceId: string ): Promise<{ flow: "unauthorized" | null; @@ -196,7 +195,7 @@ async function handleEnterpriseSignUpFlow( // The user will join this workspace if it exists; otherwise, a new workspace is created. async function handleRegularSignupFlow( session: SessionWithUser, - user: UserType, + user: UserResource, targetWorkspaceId?: string ): Promise< Result< @@ -400,7 +399,7 @@ async function handler( // Delete newly created user if SSO is mandatory. if (userCreated) { - await deleteUser(user); + await user.unsafeDelete(); } res.redirect( @@ -444,7 +443,7 @@ export async function createAndLogMembership({ workspace, role, }: { - user: UserType; + user: UserResource; workspace: Workspace; role: ActiveRoleType; }) { diff --git a/front/pages/api/poke/workspaces/index.ts b/front/pages/api/poke/workspaces/index.ts index 3f8cad3d74e75..49d48bdbad387 100644 --- a/front/pages/api/poke/workspaces/index.ts +++ b/front/pages/api/poke/workspaces/index.ts @@ -3,14 +3,13 @@ import type { NextApiRequest, NextApiResponse } from "next"; import type { FindOptions, WhereOptions } from "sequelize"; import { Op } from "sequelize"; -import { renderUserType } from "@app/lib/api/user"; import { withSessionAuthentication } from "@app/lib/api/wrappers"; import { Authenticator, getSession } from "@app/lib/auth"; import { Plan, Subscription } from "@app/lib/models/plan"; -import { User } from "@app/lib/models/user"; import { Workspace } from "@app/lib/models/workspace"; import { FREE_TEST_PLAN_CODE } from "@app/lib/plans/plan_codes"; import { MembershipResource } from "@app/lib/resources/membership_resource"; +import { UserResource } from "@app/lib/resources/user_resource"; import { isEmailValid } from "@app/lib/utils"; import { apiError } from "@app/logger/withlogging"; @@ -128,14 +127,10 @@ async function handler( let isSearchByEmail = false; if (isEmailValid(search)) { // We can have 2 users with the same email if a Google user and a Github user have the same email. - const users = await User.findAll({ - where: { - email: search, - }, - }); + const users = await UserResource.listByEmail(search); if (users.length) { const memberships = await MembershipResource.getLatestMemberships({ - users: users.map((u) => renderUserType(u)), + users, }); if (memberships.length) { conditions.push({ diff --git a/front/pages/api/user/index.ts b/front/pages/api/user/index.ts index dce0091dccb96..ab2f680246a3f 100644 --- a/front/pages/api/user/index.ts +++ b/front/pages/api/user/index.ts @@ -7,10 +7,10 @@ import * as t from "io-ts"; import * as reporter from "io-ts-reporters"; import type { NextApiRequest, NextApiResponse } from "next"; -import { updateUserFullName } from "@app/lib/api/user"; import { withSessionAuthentication } from "@app/lib/api/wrappers"; import { getSession } from "@app/lib/auth"; import { getUserFromSession } from "@app/lib/iam/session"; +import { UserResource } from "@app/lib/resources/user_resource"; import { ServerSideTracking } from "@app/lib/tracking/server"; import logger from "@app/logger/logger"; import { apiError } from "@app/logger/withlogging"; @@ -72,18 +72,26 @@ async function handler( }); } - const result = await updateUserFullName({ - user: user, - firstName: req.body.firstName, - lastName: req.body.lastName, - }); + const u = await UserResource.fetchByModelId(user.id); - if (!result) { + if (!u) { return apiError(req, res, { - status_code: 400, + status_code: 404, + api_error: { + type: "user_not_found", + message: "The user was not found.", + }, + }); + } + + const result = await u.updateName(req.body.firstName, req.body.lastName); + + if (result.isErr()) { + return apiError(req, res, { + status_code: 500, api_error: { type: "internal_server_error", - message: "Couldn't update the user.", + message: "Failed to update user name.", }, }); } diff --git a/front/pages/api/w/[wId]/assistant/conversations/[cId]/messages/[mId]/reactions/index.ts b/front/pages/api/w/[wId]/assistant/conversations/[cId]/messages/[mId]/reactions/index.ts index 59f4d5db2b707..85e30601bc399 100644 --- a/front/pages/api/w/[wId]/assistant/conversations/[cId]/messages/[mId]/reactions/index.ts +++ b/front/pages/api/w/[wId]/assistant/conversations/[cId]/messages/[mId]/reactions/index.ts @@ -99,7 +99,7 @@ async function handler( const created = await createMessageReaction(auth, { messageId, conversation, - user: user, + user, context: { username: user.username, fullName: user.fullName, @@ -123,7 +123,7 @@ async function handler( const deleted = await deleteMessageReaction(auth, { messageId, conversation, - user: user, + user, context: { username: user.username, fullName: user.fullName, diff --git a/front/pages/api/w/[wId]/members/[uId]/index.ts b/front/pages/api/w/[wId]/members/[uId]/index.ts index 8614af6225d8f..1891e5159f3bf 100644 --- a/front/pages/api/w/[wId]/members/[uId]/index.ts +++ b/front/pages/api/w/[wId]/members/[uId]/index.ts @@ -139,7 +139,7 @@ async function handler( } const member = { - ...user, + ...user.toJSON(), workspaces: [w], }; diff --git a/front/pages/login-error.tsx b/front/pages/login-error.tsx index 59b2bf4eb4036..27a9294e46182 100644 --- a/front/pages/login-error.tsx +++ b/front/pages/login-error.tsx @@ -88,7 +88,7 @@ function getErrorMessage(domain: string | null, reason: string | null) {

Not seeing it?
- Check you spam folder. + Check your spam folder.