feat(server): add isSystemSideEffect & merge createOneObject/createOneField side-effect migrations (#21673)
## Context When an object is created via the metadata API, `createOneObject` creates its side-effect entities (INDEX view + viewFields, indexes, navigation menu item, "go to" command menu item, record-page fields view, page layout/tabs/widgets) across **three separate `validateBuildAndRunWorkspaceMigration` calls**, purely because the protection behavior (mutations → overrides, delete → deactivate, reset → reactivate) was keyed on *"owned by the standard app"*, forcing the side effects into batches with different application owners. This misrepresents ownership and breaks atomicity. This PR separates two orthogonal concepts: - **Ownership** (`applicationId`), the true owner: the caller's application (the workspace custom app today, 3rd-party apps later). - **Protection** (`isSystemSideEffect`), the row was generated by the system, so user mutations route to overrides, deletion becomes deactivation, and reset restores defaults. Once side effects are re-owned to the caller, the old `applicationId === standardApp` check can no longer tell an original side-effect row from a user-added one so a dedicated `isSystemSideEffect` flag carries the protection instead. This is **PR 1 of 2** (forward-only). It makes newly created objects and fields correct; existing workspaces are handled by a follow-up backfill (see *Out of scope*). ## What this PR does - **`isSystemSideEffect` column** on the 8 affected entities (`view`, `viewField`, `indexMetadata`, `commandMenuItem`, `pageLayout`, `pageLayoutTab`, `pageLayoutWidget`, `fieldMetadata`), with `@WasIntroducedInUpgrade` + an entry in the flat-entity property configuration (`toCompare: true`, read-only). - **Single atomic migration in `createOneObject`**: the three `validateBuildAndRunWorkspaceMigration` calls are merged into one, owned by the caller (`resolvedOwnerFlatApplication`) and the record-page view/fields, page layout, and navigation command item are re-owned to the caller and flagged `isSystemSideEffect: true`. `buildNavigationFlatCommandMenuItem` is parameterized with `applicationUniversalIdentifier` (no longer hardcoded to the standard app). - **Field-creation side effects** (`createManyFields`/`createOneField` already run as a single caller-owned migration, so no re-ownership/merge was needed): the auto-created viewField is flagged `isSystemSideEffect: true`, and a new field now also propagates to the object's **INDEX/table view** (added there as a **hidden** column, `isVisible: false`) in addition to the record-page FIELDS widget. The INDEX view is targeted directly by `key = INDEX` (it is not a page-layout widget), de-duplicated per `(viewId, fieldMetadataUniversalIdentifier)` to respect the per-view unique index. The unique-field index is likewise flagged the inverse relation field stays unflagged (`isSystem: false`). - **Protection predicate** extended: `isCallerOverridingEntity` and the removal/reset split strategies now treat `isSystemSideEffect` rows as protected even when caller-owned (route to overrides / deactivate / reset) and the page-layout-reset guards allow resetting flagged entities. - **Standard compute maps** set the flag consistently so a re-sync produces no diff (standard-object side effects stay `false`; per-object nav command items and custom-object base fields are `true`). - **Read-only GraphQL exposure** of `isSystemSideEffect` on the view / view-field / page-layout / tab / widget / command-menu-item DTOs (not exposed on create/update inputs). => Todo: needs to take this new flag into account. This is fine for now because isSystem remains on object/field. - **Fast instance command** (`2-14`) adding the 8 columns (`NOT NULL DEFAULT false`). ## Scope decisions - **`pageLayout` is not an `OverridableEntity`**, its own row has nothing user-overridable (all customization lives on tabs/widgets). It's dual-purpose (`RECORD_PAGE` side-effect vs. user `DASHBOARD`), so it gets `isSystemSideEffect` for protection only, no `overrides` jsonb. - **`navigationMenuItem` is out of scope.**: Those are side effects only for the metadata API and not marked as "system" (they can be deleted/updated etc...) - **`viewFieldGroup` is not a side effect**, it's only created via the explicit view-field-group API, never by object/field creation, so it gets no flag. ## Out of scope (follow-ups) **PR 2** — slow per-workspace backfill (re-own + flag existing side effects, recreate missing ones) and deterministic v5 identifiers for base fields / pageLayout / tab. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21673?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+8
@@ -23,6 +23,7 @@ const PARTIAL_ID_FIELD = {
|
||||
isUnique: true,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: false,
|
||||
isLabelSyncedWithName: false,
|
||||
defaultValue: 'uuid',
|
||||
@@ -51,6 +52,7 @@ const PARTIAL_CREATED_AT_FIELD = {
|
||||
isUnique: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: false,
|
||||
isLabelSyncedWithName: false,
|
||||
defaultValue: 'now',
|
||||
@@ -79,6 +81,7 @@ const PARTIAL_UPDATED_AT_FIELD = {
|
||||
isUnique: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: false,
|
||||
isLabelSyncedWithName: false,
|
||||
defaultValue: 'now',
|
||||
@@ -107,6 +110,7 @@ const PARTIAL_DELETED_AT_FIELD = {
|
||||
isUnique: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: false,
|
||||
isLabelSyncedWithName: false,
|
||||
defaultValue: null,
|
||||
@@ -135,6 +139,7 @@ const PARTIAL_CREATED_BY_FIELD = {
|
||||
isUnique: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: false,
|
||||
isLabelSyncedWithName: false,
|
||||
defaultValue: { name: "''", source: "'MANUAL'" },
|
||||
@@ -163,6 +168,7 @@ const PARTIAL_UPDATED_BY_FIELD = {
|
||||
isUnique: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: false,
|
||||
isLabelSyncedWithName: false,
|
||||
defaultValue: { name: "''", source: "'MANUAL'" },
|
||||
@@ -191,6 +197,7 @@ const PARTIAL_POSITION_FIELD = {
|
||||
isUnique: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: false,
|
||||
isLabelSyncedWithName: false,
|
||||
defaultValue: 0,
|
||||
@@ -219,6 +226,7 @@ const PARTIAL_SEARCH_VECTOR_FIELD = {
|
||||
isUnique: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: false,
|
||||
isLabelSyncedWithName: false,
|
||||
defaultValue: null,
|
||||
|
||||
+71
-100
@@ -145,7 +145,9 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
existingFlatObjectMetadata,
|
||||
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
|
||||
workspaceId,
|
||||
applicationId: twentyStandardFlatApplication.id,
|
||||
applicationId: resolvedOwnerFlatApplication.id,
|
||||
applicationUniversalIdentifier:
|
||||
resolvedOwnerFlatApplication.universalIdentifier,
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
@@ -465,7 +467,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
workspaceId: string;
|
||||
ownerFlatApplication?: FlatApplication;
|
||||
}): Promise<FlatObjectMetadata> {
|
||||
const { workspaceCustomFlatApplication, twentyStandardFlatApplication } =
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
@@ -497,7 +499,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
});
|
||||
|
||||
const flatDefaultViewFieldsToCreate = computeFlatViewFieldsToCreate({
|
||||
flatApplication: workspaceCustomFlatApplication,
|
||||
flatApplication: resolvedOwnerFlatApplication,
|
||||
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
|
||||
@@ -525,11 +527,38 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
{
|
||||
objectMetadata: flatObjectMetadataToCreate,
|
||||
workspaceId,
|
||||
applicationId: twentyStandardFlatApplication.id,
|
||||
applicationId: resolvedOwnerFlatApplication.id,
|
||||
applicationUniversalIdentifier:
|
||||
resolvedOwnerFlatApplication.universalIdentifier,
|
||||
flatCommandMenuItemMaps,
|
||||
},
|
||||
);
|
||||
|
||||
const flatRecordPageFieldsViewToCreate =
|
||||
this.computeFlatRecordPageFieldsViewToCreate({
|
||||
objectMetadata: flatObjectMetadataToCreate,
|
||||
flatApplication: resolvedOwnerFlatApplication,
|
||||
});
|
||||
|
||||
const flatRecordPageFieldsViewFieldsToCreate =
|
||||
computeFlatViewFieldsToCreate({
|
||||
flatApplication: resolvedOwnerFlatApplication,
|
||||
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
|
||||
viewUniversalIdentifier:
|
||||
flatRecordPageFieldsViewToCreate.universalIdentifier,
|
||||
excludeLabelIdentifier: true,
|
||||
});
|
||||
|
||||
const flatDefaultRecordPageLayoutsToCreate =
|
||||
this.computeFlatDefaultRecordPageLayoutToCreate({
|
||||
objectMetadata: flatObjectMetadataToCreate,
|
||||
flatApplication: resolvedOwnerFlatApplication,
|
||||
recordPageFieldsView: flatRecordPageFieldsViewToCreate,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
@@ -540,12 +569,18 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
view: {
|
||||
flatEntityToCreate: [flatDefaultViewToCreate],
|
||||
flatEntityToCreate: [
|
||||
flatDefaultViewToCreate,
|
||||
flatRecordPageFieldsViewToCreate,
|
||||
],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
viewField: {
|
||||
flatEntityToCreate: flatDefaultViewFieldsToCreate,
|
||||
flatEntityToCreate: [
|
||||
...flatDefaultViewFieldsToCreate,
|
||||
...flatRecordPageFieldsViewFieldsToCreate,
|
||||
],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
@@ -562,6 +597,29 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
commandMenuItem: {
|
||||
flatEntityToCreate: [flatCommandMenuItemToCreate],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayout: {
|
||||
flatEntityToCreate:
|
||||
flatDefaultRecordPageLayoutsToCreate.pageLayouts,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayoutTab: {
|
||||
flatEntityToCreate:
|
||||
flatDefaultRecordPageLayoutsToCreate.pageLayoutTabs,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayoutWidget: {
|
||||
flatEntityToCreate:
|
||||
flatDefaultRecordPageLayoutsToCreate.pageLayoutWidgets,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
...(isDefined(flatNavigationMenuItemToCreate)
|
||||
? {
|
||||
navigationMenuItem: {
|
||||
@@ -586,100 +644,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
);
|
||||
}
|
||||
|
||||
const commandMenuItemMigrationResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
commandMenuItem: {
|
||||
flatEntityToCreate: [flatCommandMenuItemToCreate],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier:
|
||||
twentyStandardFlatApplication.universalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (commandMenuItemMigrationResult.status === 'fail') {
|
||||
throw new WorkspaceMigrationBuilderException(
|
||||
commandMenuItemMigrationResult,
|
||||
'Multiple validation errors occurred while creating command menu item',
|
||||
);
|
||||
}
|
||||
|
||||
const flatRecordPageFieldsViewToCreate =
|
||||
this.computeFlatRecordPageFieldsViewToCreate({
|
||||
objectMetadata: flatObjectMetadataToCreate,
|
||||
flatApplication: twentyStandardFlatApplication,
|
||||
});
|
||||
|
||||
const flatRecordPageFieldsViewFieldsToCreate =
|
||||
computeFlatViewFieldsToCreate({
|
||||
flatApplication: twentyStandardFlatApplication,
|
||||
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
|
||||
viewUniversalIdentifier:
|
||||
flatRecordPageFieldsViewToCreate.universalIdentifier,
|
||||
excludeLabelIdentifier: true,
|
||||
});
|
||||
|
||||
const flatDefaultRecordPageLayoutsToCreate =
|
||||
this.computeFlatDefaultRecordPageLayoutToCreate({
|
||||
objectMetadata: flatObjectMetadataToCreate,
|
||||
flatApplication: twentyStandardFlatApplication,
|
||||
recordPageFieldsView: flatRecordPageFieldsViewToCreate,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const pageLayoutMigrationResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
view: {
|
||||
flatEntityToCreate: [flatRecordPageFieldsViewToCreate],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
viewField: {
|
||||
flatEntityToCreate: flatRecordPageFieldsViewFieldsToCreate,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayout: {
|
||||
flatEntityToCreate:
|
||||
flatDefaultRecordPageLayoutsToCreate.pageLayouts,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayoutTab: {
|
||||
flatEntityToCreate:
|
||||
flatDefaultRecordPageLayoutsToCreate.pageLayoutTabs,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
pageLayoutWidget: {
|
||||
flatEntityToCreate:
|
||||
flatDefaultRecordPageLayoutsToCreate.pageLayoutWidgets,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier:
|
||||
twentyStandardFlatApplication.universalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (pageLayoutMigrationResult.status === 'fail') {
|
||||
throw new WorkspaceMigrationBuilderException(
|
||||
pageLayoutMigrationResult,
|
||||
'Multiple validation errors occurred while creating page layouts for object',
|
||||
);
|
||||
}
|
||||
|
||||
const { flatObjectMetadataMaps: recomputedFlatObjectMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
@@ -737,6 +701,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
createdByUserWorkspaceId: null,
|
||||
isActive: true,
|
||||
isSystemSideEffect: true,
|
||||
universalOverrides: null,
|
||||
viewFieldUniversalIdentifiers: [],
|
||||
viewFieldGroupUniversalIdentifiers: [],
|
||||
@@ -847,6 +812,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
objectMetadata,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
flatCommandMenuItemMaps,
|
||||
}: {
|
||||
objectMetadata: {
|
||||
@@ -859,6 +825,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
};
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
flatCommandMenuItemMaps: {
|
||||
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
|
||||
};
|
||||
@@ -876,6 +843,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
objectMetadata,
|
||||
commandMenuItemId: v4(),
|
||||
applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
position: nextPosition,
|
||||
now: new Date().toISOString(),
|
||||
@@ -909,6 +877,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
flatCommandMenuItemMaps,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
}: {
|
||||
isBeingEnabled: boolean;
|
||||
isBeingDisabled: boolean;
|
||||
@@ -918,6 +887,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
};
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
}): {
|
||||
commandMenuItemsToCreate: FlatCommandMenuItem[];
|
||||
commandMenuItemsToUpdate: FlatCommandMenuItem[];
|
||||
@@ -943,6 +913,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
objectMetadata: existingFlatObjectMetadata,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
flatCommandMenuItemMaps,
|
||||
}),
|
||||
],
|
||||
|
||||
+1
@@ -134,6 +134,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
|
||||
isNullable: true,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isSystemSideEffect: true,
|
||||
isUIEditable: true,
|
||||
defaultValue: null,
|
||||
createdAt: now,
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ export const buildDefaultIndexesForCustomObject = ({
|
||||
indexWhereClause: null,
|
||||
isCustom: false,
|
||||
isUnique: false,
|
||||
isSystemSideEffect: true,
|
||||
objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier,
|
||||
universalIdentifier: tsFlatVectorIndexUniversalIdentifier,
|
||||
updatedAt: createdAt.toISOString(),
|
||||
|
||||
+3
@@ -64,6 +64,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
|
||||
widgetIds: [widgetId],
|
||||
widgetUniversalIdentifiers: [widgetUniversalIdentifier],
|
||||
isActive: true,
|
||||
isSystemSideEffect: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
@@ -119,6 +120,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
|
||||
objectMetadataId: objectMetadata.id,
|
||||
objectMetadataUniversalIdentifier: objectMetadata.universalIdentifier,
|
||||
isActive: true,
|
||||
isSystemSideEffect: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
@@ -141,6 +143,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
|
||||
tabUniversalIdentifiers: pageLayoutTabs.map(
|
||||
(tab) => tab.universalIdentifier,
|
||||
),
|
||||
isSystemSideEffect: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ export const computeFlatRecordPageFieldsViewToCreate = ({
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
createdByUserWorkspaceId: null,
|
||||
isActive: true,
|
||||
isSystemSideEffect: true,
|
||||
universalOverrides: null,
|
||||
viewFieldUniversalIdentifiers: [],
|
||||
viewFieldGroupUniversalIdentifiers: [],
|
||||
|
||||
+1
@@ -65,6 +65,7 @@ export const computeFlatViewFieldsToCreate = ({
|
||||
position: index,
|
||||
aggregateOperation: null,
|
||||
isActive: true,
|
||||
isSystemSideEffect: true,
|
||||
universalOverrides: null,
|
||||
applicationUniversalIdentifier: flatApplication.universalIdentifier,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user