mattermost-mobile-upstream/app/actions/websocket/properties.ts
Pablo Vélez 4d842026f6
Some checks failed
ci / lint-typecheck (push) Has been cancelled
ci / detox-lint-typecheck (push) Has been cancelled
ci / test (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Mm 70564 channel labels use in channel creation (#10146)
* MM-70516 - Add ability to edit channel attribute values from Channel Info

* Add API methods for patching property fields and setting channel attribute requirements

* Update channel attribute clearing behavior for required and non-required fields

* MM-70564 - use channel attributes on channel creation

* Fix undefined propertyValues and ternary formatting in channel attributes

* Fix spelling of "colour" to "color" in attribute chip tests and related comments

* Add visibility checks for channel attribute labels in tests

* Fix data-integrity, permission, and accessibility issues in channel attributes: scoped pruning, atomic writes, race serialization, required clear/stale-option guards

* Fix handling of null values in apiGetChannelAttributeValue and update test assertions for channel attributes

* Improve channel attribute editor sizing for long labels, keep Save visible, and prevent keyboard overlap on Android

* Address CodeRabbit feedback for nullable values, bottom-sheet state, async validation, and reliable E2E cleanup

* lock-safe field fetch, create-time gate recheck, scoped value pruning, and tappable header chips

* Address CodeRabbit feedback: drop queued attribute saves on channel switch and use page object selectors in E2E

* Respect the ChannelAttributesRequired flag on channel creation and enable it for channel attribute E2E

* Skip required channel attribute E2E tests and stop enabling ChannelAttributesRequired on the shared server until  they move to SITE_3

* Fix clearing channel attributes on iOS by sending an empty value instead of null, and stop E2E suites from turning  off the ChannelAttributes flag mid-run

* Refactor getEmptyPropertyValue to accept string type for fieldType parameter

* E2E: give channel attribute test fields a suite-unique suffix

Pool shards may only create optional property fields named <name>_<6 hex>; fixed names were blocked by the pool guard.

* E2E: restore channel attribute cleanup checks; name network error codes

Brings back the PR's afterEach/afterAll error handling that the main merge replaced, and makes a no-response error show its codes (an AggregateError has an empty message).

* E2E: run channel attribute creation and editing specs on server B

Their 11 required-attribute tests could not run on the shared servers. Both specs now run on the mutation server with ChannelAttributesRequired on for their run, and the tests are re-enabled. setChannelAttributesRequired moves to the shared helper.

* Check header chips instead of their wrapper and use toExist for the create screen in channel attribute E2E

---------

Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
2026-10-30 15:03:55 +01:00

186 lines
8.9 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {channelAttributeWriteLockKey} from '@actions/remote/channel_attributes';
import {accessControlWriteLockKey, fetchAccessControlAttributeFields, fetchChannelAttributeValues} from '@actions/remote/classification';
import {CHANNEL_ATTRIBUTE_OBJECT_TYPE, OWNED_OBJECT_TYPES} from '@constants/channel_attributes';
import DatabaseManager from '@database/manager';
import {getAccessControlGroupId, getPropertyValuesByFieldId} from '@queries/servers/properties';
import EphemeralStore from '@store/ephemeral_store';
import {getFullErrorMessage} from '@utils/errors';
import {safeParseJSON} from '@utils/helpers';
import {logDebug, logError} from '@utils/log';
import type {PropertyValueModel} from '@database/models/server';
import type ServerDataOperator from '@database/operator/server_data_operator';
export async function handlePropertyFieldCreatedOrUpdated(serverUrl: string, msg: WebSocketMessage) {
const data = msg.data as {property_field?: string; object_type?: string};
if (!data.property_field) {
logDebug('handlePropertyFieldCreatedOrUpdated', 'No property_field in WS event');
return;
}
const field = safeParseJSON(data.property_field) as PropertyField | string;
if (typeof field === 'string') {
logDebug('handlePropertyFieldCreatedOrUpdated', 'Failed to parse property_field from WS event');
return;
}
try {
const needsFieldFetch = await EphemeralStore.runExclusive(accessControlWriteLockKey(serverUrl), async () => {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = await operator.handlePropertyFields({fields: [field], prepareRecordsOnly: true});
await operator.batchRecords(models, 'handlePropertyFieldCreatedOrUpdated', true);
// The event carries a group_id but no group name, so there is no way to tell
// from here whether this field is ours. What we can tell is that we do not
// know our group id yet — which happens when the last fetch found no fields
// and stamped its one-hour cache. Without this, the first attribute an
// administrator creates stays invisible for the rest of that hour.
//
// Narrowed to the object types this feature owns, and self-limiting: once
// the fetch publishes the id, this stops firing.
return OWNED_OBJECT_TYPES.has(field.object_type) && !(await getAccessControlGroupId(database));
});
// Called after the lock is released: the fetch takes the same lock, so
// awaiting it from inside would wait on itself forever.
if (needsFieldFetch) {
await fetchAccessControlAttributeFields(serverUrl, true);
}
} catch (error) {
logError('handlePropertyFieldCreatedOrUpdated', getFullErrorMessage(error));
}
}
export async function handlePropertyFieldDeleted(serverUrl: string, msg: WebSocketMessage) {
const data = msg.data as {field_id?: string; object_type?: string};
if (!data.field_id) {
logDebug('handlePropertyFieldDeleted', 'No field_id in WS event');
return;
}
try {
await EphemeralStore.runExclusive(accessControlWriteLockKey(serverUrl), async () => {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = await operator.handlePropertyFields({fields: [{id: data.field_id, delete_at: Date.now()} as PropertyField], prepareRecordsOnly: true});
await operator.batchRecords(models, 'handlePropertyFieldDeleted', true);
});
} catch (error) {
logError('handlePropertyFieldDeleted', getFullErrorMessage(error));
}
}
// Stamping delete_at is how the operator is told to remove a row; it has no
// delete-by-query path for values. Prepared and committed as one propagated
// batch so a failed write throws rather than silently leaving stale rows.
async function destroyValues(serverUrl: string, operator: ServerDataOperator, stale: PropertyValueModel[], description: string) {
if (!stale.length) {
return;
}
const lockKeys = [...new Set(stale.map((value) => {
if (value.targetType === 'channel') {
return channelAttributeWriteLockKey(serverUrl, value.targetId);
}
if (value.targetType === 'system') {
return accessControlWriteLockKey(serverUrl);
}
return '';
}).filter(Boolean))].sort();
const commit = async () => {
const models = await operator.handlePropertyValues({
values: stale.map((v) => ({id: v.id, delete_at: Date.now()} as PropertyValue)),
prepareRecordsOnly: true,
});
await operator.batchRecords(models, description, true);
};
const withLock = (index: number): Promise<void> => {
const key = lockKeys[index];
return key ? EphemeralStore.runExclusive(key, () => withLock(index + 1)) : commit();
};
await withLock(0);
}
export async function handlePropertyValuesUpdated(serverUrl: string, msg: WebSocketMessage) {
const data = msg.data as PropertyValuesUpdatedData;
if (!data.values) {
return;
}
const values = safeParseJSON(data.values) as Array<PropertyValue<string>> | string;
if (typeof values === 'string') {
logDebug('handlePropertyValuesUpdated', 'Failed to parse values from WS event');
return;
}
// The server sends this event in four shapes, and they need different writes:
//
// upsert -> target_id + only the values just written
// single delete -> target_id + that one value, delete_at set
// all values on target -> target_id + an empty list
// all values on a field -> field_id + an empty list
//
// So a non-empty list is a merge, never authoritative: passing targetId there
// would prune every other attribute on the channel whenever one is edited. An
// empty list is the opposite — it carries no ids, so it can only be acted on
// as a prune, which is why it must not be treated as a no-op.
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
if (values.length) {
const persistValues = async () => {
const models = await operator.handlePropertyValues({values, prepareRecordsOnly: true});
await operator.batchRecords(models, 'handlePropertyValuesUpdated', true);
};
const targetId = data.target_id ?? values[0].target_id;
if (values.some((value) => value.target_type === 'channel')) {
// Serialized against the REST full-channel fetch and
// setChannelAttributeValue for the same channel, so this upsert
// cannot land in the gap between that fetch's read and its write.
await EphemeralStore.runExclusive(channelAttributeWriteLockKey(serverUrl, targetId), persistValues);
return;
}
if (values.some((value) => value.target_type === 'system')) {
await EphemeralStore.runExclusive(accessControlWriteLockKey(serverUrl), persistValues);
return;
}
await persistValues();
return;
}
if (data.target_id) {
// This event carries no group identity, so an "all cleared" broadcast
// from another property group sharing this table (managed channel
// categories, custom profile attributes) is indistinguishable from one
// for access_control. Deleting locally here would apply someone else's
// clear to our data. Instead, force a group-scoped REST reconciliation:
// if the clear really was ours, the fetch will observe it and prune;
// if it belonged to another group, the fetch finds our values
// unchanged and nothing is lost. Non-channel targets (users, posts,
// system) can never hold channel attributes, so they are skipped.
if (data.object_type !== CHANNEL_ATTRIBUTE_OBJECT_TYPE) {
logDebug('handlePropertyValuesUpdated', 'Ignoring cleared values for non-channel object type', data.object_type);
return;
}
await fetchChannelAttributeValues(serverUrl, data.target_id, true);
return;
}
if (data.field_id) {
// Safe to key on the field alone: a value belongs to exactly one field,
// so this cannot reach another group's rows.
await destroyValues(serverUrl, operator, await getPropertyValuesByFieldId(database, data.field_id), 'handlePropertyValuesUpdated');
return;
}
logDebug('handlePropertyValuesUpdated', 'Empty values with no target or field to prune');
} catch (error) {
logError('handlePropertyValuesUpdated', getFullErrorMessage(error));
}
}