mattermost-mobile-upstream/app/actions/remote/classification.ts
Pablo Vélez 54731fa6ac
Mm 70299 channel labels display phase 1 (#10113)
* MM-70299 - display channel attributes in header, info and banner

* cover and expand channel attributes feature with UI and E2E tests

* Implement channel attributes lock and update related tests and API methods

* Enhance error logging and improve type definitions for channel attributes

* Handle error from setAccessControlGroupId in fetchAccessControlAttributeFields

* Refactor fetchAccessControlAttributeFields to include system ID handling and improve batch record persistence

* Address code review feedback: extract feature-flag constants, move DB-only helpers to query/local layers, refactor attribute chip styles, fix colour spelling, and add test coverage for value pruning and subtitle component

* Fix error handling in removeStoredFields function and delete obsolete end-to-end test file

* Add tests for removeStoredFields function and enhance property queries

* Fix removeStoredFields to recover group ID from classification anchor and clean all owned fields

* Fix batchRecords silently swallowing write errors in fetchAccessControlAttributeFields

* Remove unused visibility check for ChannelAttributeLabels in e2e tests

* Fix classification fetch cache and channel-attributes E2E lock contention

Do not stamp the classification banner cache when both access-control flags
are off, so turning a flag back on after a reload refetches fields.

Share the classification lock with the channel-attributes Detox suite and
stop unsetting ClassificationMarkings. Skip flag-on tests when the server
cannot enable ChannelAttributes.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Scope access_control field prune to owned object types

handlePropertyFields(groupId) deletes every field in the group. The fetch
only returns system and channel fields, so same-group user/session rows
were removed on each refresh. Prune only system and channel fields, matching
removeStoredFields.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* fail channel attributes tests when feature enablement fails instead of silently reporting them as passed

* Compose all channel attribute values in banners with template resolution, mixed field support, and matching web colors

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: mattermost-code <matty-code@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-09-17 21:37:43 +02:00

274 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 {ACCESS_CONTROL_GROUP_NAME, CHANNEL_ATTRIBUTE_OBJECT_TYPE, FEATURE_FLAG_CHANNEL_ATTRIBUTES, 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 {getAccessControlValuesForTarget, getPropertyFieldsByGroupId, 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';
// Only these types resolve a stored value against the field's option list. A text
// attribute stores its display string directly, so treating an unmatched value as
// a stale option would force a field refetch on every text value ever set.
const OPTION_BACKED_TYPES = new Set<PropertyFieldType>(['select', 'multiselect', 'rank']);
/**
* 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 object
* types this feature owns (`system` and `channel`). User and session fields
* share the group and must not be pruned.
*/
export async function fetchAccessControlAttributeFields(serverUrl: string, force = false): Promise<{error?: unknown}> {
if (!force && !EphemeralStore.shouldFetchClassificationBanner(serverUrl)) {
logDebug('fetchAccessControlAttributeFields', 'skipped; cached data still fresh');
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');
// Set the TTL even on this error path so a bad server
// response does not produce a repeated fetch-and-fail loop
// for every non-forced caller within the cache lifetime.
EphemeralStore.setClassificationBannerFetched(serverUrl);
return {};
}
const values = await client.getSystemPropertyValues<string>(ACCESS_CONTROL_GROUP_NAME);
// Do not pass groupId: that prune is unscoped by object_type and
// would delete user/session fields that share access_control.
const existing = await getPropertyFieldsByGroupId(database, groupId);
const incomingIds = new Set(allFields.filter((f) => f.delete_at === 0).map((f) => f.id));
const staleOwned = existing.filter((f) =>
OWNED_OBJECT_TYPES.has(f.objectType as PropertyFieldObjectType) && !incomingIds.has(f.id),
);
const fieldModels = await operator.handlePropertyFields({
fields: [
...allFields,
...staleOwned.map((f) => ({id: f.id, delete_at: Date.now()} as PropertyField)),
],
prepareRecordsOnly: true,
});
const valueModels = await operator.handlePropertyValues({targetId: CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, 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');
// Authoritative empty result: stamp the cache so we do not refetch
// empty for the rest of the TTL.
await removeStoredFields(serverUrl);
EphemeralStore.setClassificationBannerFetched(serverUrl);
return {};
}
// Features off: drop local rows so chips/banner go away, but do not stamp
// the fetch cache. Stamping here would skip the next mount after a flag is
// turned back on — reload resets the flagChanged ref in the banner container.
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);
const values = await client.getPropertyValues<string>(ACCESS_CONTROL_GROUP_NAME, CHANNEL_ATTRIBUTE_OBJECT_TYPE, channelId);
// Upsert without targetId: passing targetId to handlePropertyValues makes
// the write authoritative by deleting every PropertyValue WHERE
// target_id=channelId that is not in the list. The table is shared across
// groups — managed channel categories and future features may store
// per-channel values in it — so a group-unscoped delete is data loss.
// Instead, we upsert the returned values and separately prune stale rows
// that belong to the access_control group.
await operator.handlePropertyValues({values, prepareRecordsOnly: false});
// Prune access_control values the server no longer returns.
// getAccessControlValuesForTarget returns [] when the group id is not yet
// known, which is the safe direction for a destructive call.
const existing = await getAccessControlValuesForTarget(database, channelId);
const incomingIds = new Set(values.map((v) => v.id));
const stale = existing.filter((v) => !incomingIds.has(v.id));
if (stale.length) {
await operator.handlePropertyValues({
values: stale.map((v) => ({id: v.id, delete_at: Date.now()} as PropertyValue)),
prepareRecordsOnly: false,
});
}
await resyncStaleOptions(serverUrl, database, values);
EphemeralStore.setChannelAttributeValuesSynced(serverUrl, channelId);
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_TYPES.has(field.type as PropertyFieldType)) {
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);
}
}
}