Twenty standard and workspace custom applications 1/3 (#15625)

# Introduction
related to https://github.com/twentyhq/core-team-issues/issues/1833
In this PR we're starting the sync-metadata and standardIds deprecation
by introducing `twenty-standard` application that will regroup every
standard object such as company and opportunities. But also the
`custom-workspace-application` which is an app created at the same time
as a workspace and that will regroup everything configure within the
workspace ( custom objects fields etc )

## What's done
On both new workspace and seeded workspace creation:
- Creating a custom workspace app
- Creating a twenty standard app
- Refactored the seed core schema and workspace creation to be run
within a transaction in order to handle circular dependency foreignkey
requirements ( which is deferred for app toward workspace )
- Updated workspace entity to have a custom workspace relation (
nullable for the moment until we implem an upgrade command to handle
retro comp )
- Integration testing on user, workspace creation deletion and expected
default apps creation
- ~~Soft deleted user on `deleteUser`~~ Done by marie and rebased on it

## What's next
- Update seeder to propagate the `twenty-standard` workspace
`applicationId` to every standard synchronized entities ( cheap and fast
iteration through the about to be deprecated sync-metadata as an easy
way to synchronize standards metadata entities ).
- Update seeder to propagate the `custom-workspace-application`
workspace `applicationId` to anything custom ( `pets` and `rockets` )
- Prepend `custom-workspace-application` `applicationId` to every
metadata API operations ( create a specific cache etc )
- Upgrade command on all existing workspace to create a custom app and
associate its applicationId to any existing custom entities
- Make `universalIdentifier` and `applicationId` required for any
syncable entity
This commit is contained in:
Paul Rastoin
2025-11-10 16:13:12 +01:00
committed by GitHub
parent 9a80164cf3
commit 7a1e699fc8
106 changed files with 2167 additions and 985 deletions
@@ -0,0 +1,62 @@
import gql from 'graphql-tag';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
type ActivateWorkspaceUtilArgs = {
accessToken: string;
displayName: string;
expectToFail?: boolean;
};
export const activateWorkspace = async ({
accessToken,
displayName,
expectToFail,
}: ActivateWorkspaceUtilArgs): CommonResponseBody<{
activateWorkspace: WorkspaceEntity;
}> => {
const mutation = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) {
id
displayName
activationStatus
subdomain
inviteHash
logo
}
}
`;
const response = await makeGraphqlAPIRequest(
{
query: mutation,
variables: {
input: {
displayName,
},
},
},
accessToken,
);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Activate workspace should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Activate workspace has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,55 @@
import gql from 'graphql-tag';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
type DeleteUserUtilArgs = {
accessToken: string;
expectToFail?: boolean;
};
export const deleteUser = async ({
accessToken,
expectToFail,
}: DeleteUserUtilArgs): CommonResponseBody<{
deleteUser: UserEntity;
}> => {
const mutation = gql`
mutation DeleteUser {
deleteUser {
id
email
firstName
lastName
deletedAt
}
}
`;
const response = await makeGraphqlAPIRequest(
{
query: mutation,
variables: {},
},
accessToken,
);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Delete user should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Delete user has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,57 @@
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import gql from 'graphql-tag';
import { type ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
export const APPLICATION_GQL_FIELDS = `
id
name
description
version
universalIdentifier
`;
export const findManyApplications = async ({
gqlFields = APPLICATION_GQL_FIELDS,
expectToFail,
accessToken,
}: {
gqlFields?: string;
expectToFail?: boolean;
accessToken?: string;
}): CommonResponseBody<{
findManyApplications: ApplicationDTO[];
}> => {
const response = await makeGraphqlAPIRequest(
{
query: gql`
query FindManyApplications {
findManyApplications {
${gqlFields}
}
}
`,
variables: {},
},
accessToken,
);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Application search should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Application search has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,70 @@
import gql from 'graphql-tag';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { type AuthTokens } from 'src/engine/core-modules/auth/dto/auth-tokens.dto';
type GetAuthTokensFromLoginTokenUtilArgs = {
loginToken: string;
origin?: string;
expectToFail?: boolean;
};
export const getAuthTokensFromLoginToken = async ({
loginToken,
origin = 'http://localhost:3001',
expectToFail,
}: GetAuthTokensFromLoginTokenUtilArgs): CommonResponseBody<{
getAuthTokensFromLoginToken: AuthTokens;
}> => {
const mutation = gql`
mutation GetAuthTokensFromLoginToken(
$loginToken: String!
$origin: String!
) {
getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) {
tokens {
accessOrWorkspaceAgnosticToken {
token
expiresAt
}
refreshToken {
token
expiresAt
}
}
}
}
`;
const response = await makeGraphqlAPIRequest(
{
query: mutation,
variables: {
loginToken,
origin,
},
},
undefined, // Public endpoint - no authentication required
);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'Get auth tokens from login token should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage:
'Get auth tokens from login token has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,76 @@
import gql from 'graphql-tag';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
type CurrentUserUtilArgs = {
accessToken: string;
expectToFail?: boolean;
};
export const getCurrentUser = async ({
accessToken,
expectToFail,
}: CurrentUserUtilArgs): CommonResponseBody<{
currentUser: UserEntity;
}> => {
const query = gql`
query CurrentUser {
currentUser {
id
email
firstName
lastName
defaultAvatarUrl
isEmailVerified
disabled
canImpersonate
canAccessFullAdminPanel
locale
createdAt
updatedAt
deletedAt
currentWorkspace {
id
displayName
subdomain
activationStatus
logo
workspaceCustomApplicationId
}
currentUserWorkspace {
id
userId
workspaceId
}
}
}
`;
const response = await makeGraphqlAPIRequest(
{
query,
variables: {},
},
accessToken,
);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Get current user should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Get current user has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -1,19 +1,26 @@
import { type ASTNode, print } from 'graphql';
import request from 'supertest';
import { isDefined } from 'twenty-shared/utils';
type GraphqlOperation = {
query: ASTNode;
variables?: Record<string, unknown>;
};
export const makeGraphqlAPIRequest = (graphqlOperation: GraphqlOperation) => {
export const makeGraphqlAPIRequest = (
graphqlOperation: GraphqlOperation,
token: string | undefined = APPLE_JANE_ADMIN_ACCESS_TOKEN,
) => {
const client = request(`http://localhost:${APP_PORT}`);
return client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: print(graphqlOperation.query),
variables: graphqlOperation.variables || {},
});
const clientInstance = client.post('/graphql');
if (isDefined(token)) {
clientInstance.set('Authorization', `Bearer ${token}`);
}
return clientInstance.send({
query: print(graphqlOperation.query),
variables: graphqlOperation.variables || {},
});
};
@@ -0,0 +1,53 @@
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { searchFactory } from 'test/integration/graphql/utils/search-factory.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
import { type SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto';
type SearchOperationArgs = SearchArgs & {
expectToFail?: boolean;
accessToken?: string;
};
export const search = async ({
searchInput,
limit,
after,
includedObjectNameSingulars,
excludedObjectNameSingulars,
filter,
expectToFail,
accessToken,
}: SearchOperationArgs): CommonResponseBody<{
search: SearchResultConnectionDTO;
}> => {
const graphqlOperation = searchFactory({
searchInput,
limit,
after,
includedObjectNameSingulars,
excludedObjectNameSingulars,
filter,
});
const response = await makeGraphqlAPIRequest(graphqlOperation, accessToken);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Search should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Search has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,61 @@
import gql from 'graphql-tag';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { type SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
type SignUpOnNewWorkspaceUtilArgs = {
accessToken: string;
expectToFail?: boolean;
};
export const signUpInNewWorkspace = async ({
accessToken,
expectToFail,
}: SignUpOnNewWorkspaceUtilArgs): CommonResponseBody<{
signUpInNewWorkspace: SignUpOutput;
}> => {
const mutation = gql`
mutation SignUpInNewWorkspace {
signUpInNewWorkspace {
loginToken {
token
expiresAt
}
workspace {
id
workspaceUrls {
customUrl
subdomainUrl
}
}
}
}
`;
const response = await makeGraphqlAPIRequest(
{
query: mutation,
variables: {},
},
accessToken,
);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Sign up on new workspace should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Sign up on new workspace has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -1,4 +1,4 @@
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
export const signUpOperationFactory = ({
email,
@@ -0,0 +1,87 @@
import gql from 'graphql-tag';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { type AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output';
import { type UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input';
type SignUpUtilArgs = {
input: UserCredentialsInput;
expectToFail?: boolean;
};
export const signUp = async ({
input,
expectToFail,
}: SignUpUtilArgs): CommonResponseBody<{
signUp: AvailableWorkspacesAndAccessTokensOutput;
}> => {
const mutation = gql`
mutation SignUp(
$email: String!
$password: String!
$captchaToken: String
) {
signUp(email: $email, password: $password, captchaToken: $captchaToken) {
tokens {
accessOrWorkspaceAgnosticToken {
token
expiresAt
}
refreshToken {
token
expiresAt
}
}
availableWorkspaces {
availableWorkspacesForSignIn {
id
displayName
logo
workspaceUrls {
customUrl
subdomainUrl
}
}
availableWorkspacesForSignUp {
id
displayName
logo
workspaceUrls {
customUrl
subdomainUrl
}
}
}
}
}
`;
const response = await makeGraphqlAPIRequest(
{
query: mutation,
variables: {
...input,
},
},
undefined, // Public endpoint - no authentication required
);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Sign up should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Sign up has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};