mattermost-mobile-upstream/app/actions/remote/classification.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

276 lines
14 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Fetches for the access_control property group. Despite the file name this is no
// longer classification-specific: the group holds the classification system and
// channel fields alongside every other channel attribute, and the requests here
// were already scoped by group rather than by field name.
import {removeStoredFields} from '@actions/local/channel_attributes';
import {channelAttributeWriteLockKey} from '@actions/remote/channel_attributes';
import {ACCESS_CONTROL_GROUP_NAME, CHANNEL_ATTRIBUTE_OBJECT_TYPE, FEATURE_FLAG_CHANNEL_ATTRIBUTES, OPTION_BACKED_FIELD_TYPES, OWNED_OBJECT_TYPES} from '@constants/channel_attributes';
import {
CLASSIFICATIONS_FIELD_TARGET_ID,
CLASSIFICATIONS_FIELD_TARGET_TYPE,
CLASSIFICATIONS_SYSTEM_OBJECT_TYPE,
CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID,
} from '@constants/classification';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PROPERTY_FIELDS_SEARCH_VERSION} from '@constants/versions';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {getAccessControlGroupId, getPropertyFieldsByIds, isAccessControlPropertiesEnabled} from '@queries/servers/properties';
import {getConfigValue} from '@queries/servers/system';
import EphemeralStore from '@store/ephemeral_store';
import {getFullErrorMessage} from '@utils/errors';
import {isMinimumServerVersion} from '@utils/helpers';
import {logDebug, logError} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
import type {Database} from '@nozbe/watermelondb';
export function accessControlWriteLockKey(serverUrl: string): string {
return `access-control:${serverUrl}`;
}
/**
* Fetches every field definition in the access_control group, plus the system
* values that drive the global classification banner.
*
* One request covers both features: the group holds the classification system and
* channel fields and every other channel attribute, and the search is scoped by
* group rather than by field name. The write is authoritative for the group, so a
* field deleted server-side disappears locally without a reload.
*/
export async function fetchAccessControlAttributeFields(serverUrl: string, force = false): Promise<{error?: unknown}> {
if (!force && !EphemeralStore.shouldFetchClassificationBanner(serverUrl)) {
logDebug('fetchAccessControlAttributeFields', 'skipped; cached data still fresh');
return {};
}
return EphemeralStore.runExclusive(accessControlWriteLockKey(serverUrl), async () => {
if (!force && !EphemeralStore.shouldFetchClassificationBanner(serverUrl)) {
return {};
}
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
if (await isAccessControlPropertiesEnabled(database)) {
const client = NetworkManager.getClient(serverUrl);
const serverVersion = await getConfigValue(database, 'Version');
let allFields: PropertyField[];
if (isMinimumServerVersion(serverVersion, ...PROPERTY_FIELDS_SEARCH_VERSION)) {
allFields = await client.searchPropertyFields(ACCESS_CONTROL_GROUP_NAME, {
object_types: [CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CHANNEL_ATTRIBUTE_OBJECT_TYPE],
target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE,
target_id: CLASSIFICATIONS_FIELD_TARGET_ID,
});
} else {
const [systemFields, channelFields] = await Promise.all([
client.getPropertyFields(ACCESS_CONTROL_GROUP_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID),
client.getPropertyFields(ACCESS_CONTROL_GROUP_NAME, CHANNEL_ATTRIBUTE_OBJECT_TYPE, CLASSIFICATIONS_FIELD_TARGET_TYPE, CLASSIFICATIONS_FIELD_TARGET_ID),
]);
allFields = [...systemFields, ...channelFields];
}
if (allFields.length > 0) {
const groupId = allFields[0].group_id;
if (!groupId || allFields.some((f) => f.group_id !== groupId)) {
logError('fetchAccessControlAttributeFields', 'Unexpected access control fields');
// A short backoff, not the 1-hour success TTL: this suppresses
// a fetch-and-fail loop from non-forced callers without
// hiding a genuine fix (or a forced retry) for an hour, and
// existing local data is left untouched.
EphemeralStore.setClassificationBannerFailed(serverUrl);
return {error: 'access control fields returned an unexpected group_id'};
}
const values = await client.getSystemPropertyValues<string>(ACCESS_CONTROL_GROUP_NAME);
// objectTypes scopes the authoritative prune to what this request
// actually fetched: only system+channel object types. Without it,
// the group's user/session fields (owned by other features) would
// read as "missing from the response" and be deleted.
const fieldModels = await operator.handlePropertyFields({groupId, fields: allFields, objectTypes: [...OWNED_OBJECT_TYPES], prepareRecordsOnly: true});
const valueModels = await operator.handlePropertyValues({
targetId: CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID,
groupId,
values,
prepareRecordsOnly: true,
});
// Published for the field observables, which cannot scope
// themselves to this group until the id is known. Persisted in the
// same batch so field records never exist without a group ID: a
// failed write leaves the DB as it was rather than storing
// unscopeable rows.
const systemIdModels = await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.ACCESS_CONTROL_GROUP_ID, value: groupId}],
prepareRecordsOnly: true,
});
await operator.batchRecords([...fieldModels, ...valueModels, ...systemIdModels], 'fetchAccessControlAttributeFields', true);
EphemeralStore.setClassificationBannerFetched(serverUrl);
return {};
}
logDebug('fetchAccessControlAttributeFields', 'No access control fields returned');
await removeStoredFields(serverUrl);
EphemeralStore.setClassificationBannerFetched(serverUrl);
return {};
}
// Do not stamp the success cache while disabled. A subsequent flag
// enablement must be able to fetch immediately.
logDebug('fetchAccessControlAttributeFields', 'Access control features disabled; skipping fetch');
await removeStoredFields(serverUrl);
return {};
} catch (error) {
logError('fetchAccessControlAttributeFields', 'Failed to fetch access control attribute fields', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
});
}
/**
* Fetches every access_control value on one channel.
*
* The endpoint is single-target, so this is one request per channel. It is called
* from the channel-switch fan-out rather than from a component, because the chips
* and the Channel Info section need these values on channels that render no
* banner. Repeat switches are deduped; websocket events keep the values fresh
* afterwards.
*/
export async function fetchChannelAttributeValues(serverUrl: string, channelId: string, force = false): Promise<{error?: unknown}> {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
// Channel attribute values are per-channel and only needed when the
// channel attributes feature is on. Classification markings uses the
// system-level values fetched by fetchAccessControlAttributeFields, not
// per-channel values, so the OR gate would cause a wasted network call
// on every channel switch when only classification is enabled.
if ((await getConfigValue(database, FEATURE_FLAG_CHANNEL_ATTRIBUTES)) !== 'true') {
return {};
}
if (!force && EphemeralStore.getChannelAttributeValuesSynced(serverUrl, channelId)) {
return {};
}
const client = NetworkManager.getClient(serverUrl);
let values: Array<PropertyValue<string>> | undefined;
// The request itself is serialized with websocket and local writes. Locking
// only after the response arrives would still let an older REST request
// commit after a newer websocket update and prune that update as stale.
await EphemeralStore.runExclusive(channelAttributeWriteLockKey(serverUrl, channelId), async () => {
// Concurrent non-forced callers can both pass the check above before
// either enters the queue. Re-check here so only the first one fetches.
if (!force && EphemeralStore.getChannelAttributeValuesSynced(serverUrl, channelId)) {
return;
}
values = await client.getPropertyValues<string>(ACCESS_CONTROL_GROUP_NAME, CHANNEL_ATTRIBUTE_OBJECT_TYPE, channelId);
// The PropertyValue table is shared across groups (managed channel
// categories and other features store per-channel values in it), so the
// prune is scoped to the access_control group. Until that group id is
// known, upsert only: an unscoped prune would delete other groups' rows.
const groupId = await getAccessControlGroupId(database);
const models = await operator.handlePropertyValues({
values,
...(groupId ? {targetId: channelId, groupId} : {}),
prepareRecordsOnly: true,
});
await operator.batchRecords(models, 'fetchChannelAttributeValues', true);
EphemeralStore.setChannelAttributeValuesSynced(serverUrl, channelId);
});
if (values) {
await resyncStaleOptions(serverUrl, database, values);
}
return {};
} catch (error) {
logError('fetchChannelAttributeValues', 'Failed to fetch channel attribute values', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
}
/**
* Forces one field refresh when a value references an option the local field
* definitions do not know about.
*
* The server propagates option changes from a template to its linked fields, so
* this only covers a missed websocket event or a cache older than the change. The
* per-option guard is what stops an option that is genuinely gone server-side
* from causing a refetch loop.
*/
async function resyncStaleOptions(serverUrl: string, database: Database, values: Array<PropertyValue<string>>) {
// A multiselect value is an array, so both shapes have to be flattened before
// anything can be compared against a field's option list.
// Cache key is fieldId:optionId — scoped to the field so a short option ID
// string (e.g. 'SECRET') on one field cannot collide with the same string
// used as an option ID on a different field.
const candidates: Array<{fieldId: string; optionId: string; cacheKey: string}> = [];
for (const value of values) {
const raw: unknown = value.value;
const optionIds = Array.isArray(raw) ? raw : [raw];
for (const optionId of optionIds) {
const cacheKey = `${value.field_id}:${optionId}`;
if (typeof optionId === 'string' && optionId && !EphemeralStore.getClassificationFieldSyncAttempted(serverUrl, cacheKey)) {
candidates.push({fieldId: value.field_id, optionId, cacheKey});
}
}
}
if (!candidates.length) {
return;
}
// Looked up by the values' own field ids rather than by group, so this still
// works before any field fetch has run and taught us the group id — which is
// exactly the case where the definitions are most likely to be missing.
const fields = await getPropertyFieldsByIds(database, candidates.map((candidate) => candidate.fieldId));
const fieldsById = new Map(fields.map((field) => [field.id, field]));
const stale = candidates.filter(({fieldId, optionId}) => {
const field = fieldsById.get(fieldId);
// An unknown field is itself a reason to resync: the value arrived for
// something the local definitions do not have.
if (!field) {
return true;
}
if (!OPTION_BACKED_FIELD_TYPES.has(field.type)) {
return false;
}
const options = field.attrs?.options ?? [];
return !options.some((option) => option.id === optionId);
});
if (!stale.length) {
return;
}
// One refresh answers every stale option in the batch, so all of them are
// marked. Marking only the first left the rest unguarded while the channel was
// already deduped, so they were never resolved and never retried.
const {error} = await fetchAccessControlAttributeFields(serverUrl, true);
if (!error) {
for (const {cacheKey} of stale) {
EphemeralStore.setClassificationFieldSyncAttempted(serverUrl, cacheKey);
}
}
}