mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(profile): move profile timezone from staff to an experiment (#3011)
This commit is contained in:
@@ -23,6 +23,10 @@ import {
|
||||
GatewayRolloutConfigResponse,
|
||||
GatewayRolloutConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
ProfileTimezoneConfigResponse,
|
||||
ProfileTimezoneConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {PushRelayConfigResponse, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
@@ -656,6 +660,7 @@ export const InstanceConfigResponse = z.object({
|
||||
push_relay: PushRelayConfigResponse,
|
||||
domain_migration: DomainMigrationConfigResponse,
|
||||
altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
profile_timezone: ProfileTimezoneConfigResponse,
|
||||
experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
registration: InstanceRegistrationResponse,
|
||||
self_hosted: z.boolean(),
|
||||
@@ -695,6 +700,7 @@ export const InstanceConfigUpdateRequest = z.object({
|
||||
push_relay: PushRelayConfigUpdateRequest.nullish(),
|
||||
domain_migration: DomainMigrationConfigUpdateRequest.nullish(),
|
||||
altcha_captcha: AltchaCaptchaConfigUpdateRequest.nullish(),
|
||||
profile_timezone: ProfileTimezoneConfigUpdateRequest.nullish(),
|
||||
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
|
||||
registration: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
type ProfileTimezoneConfig,
|
||||
ProfileTimezoneConfigSchema,
|
||||
ProfileTimezoneConfigUpdateRequest,
|
||||
resolveProfileTimezoneAssignment,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const TARGETED_USER_ID = '1000000000000000001';
|
||||
|
||||
function createConfig(overrides: Partial<ProfileTimezoneConfig> = {}): ProfileTimezoneConfig {
|
||||
return {...DEFAULT_PROFILE_TIMEZONE_CONFIG, included_user_ids: [], excluded_user_ids: [], ...overrides};
|
||||
}
|
||||
|
||||
function syntheticUserIds(count: number): Array<string> {
|
||||
return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString());
|
||||
}
|
||||
|
||||
describe('profile timezone configuration', () => {
|
||||
test('defaults to disabled', () => {
|
||||
expect(ProfileTimezoneConfigSchema.parse({})).toEqual({
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: 'profile-timezone-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('strips config_version from admin updates', () => {
|
||||
expect(ProfileTimezoneConfigUpdateRequest.safeParse({config_version: 3}).data).toEqual({});
|
||||
expect(ProfileTimezoneConfigUpdateRequest.safeParse({rollout_basis_points: 10001}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProfileTimezoneAssignment', () => {
|
||||
test('serves nobody while disabled, even included users', () => {
|
||||
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('applies exclusions before inclusions', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
included_user_ids: [TARGETED_USER_ID],
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('serves included users at zero rollout', () => {
|
||||
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
test('buckets the rollout by salt and user id', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 2500});
|
||||
for (const userId of syntheticUserIds(200)) {
|
||||
expect(resolveProfileTimezoneAssignment(config, userId).enabled).toBe(
|
||||
experimentBucket(userId, config.rollout_salt) < 2500,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {z} from 'zod';
|
||||
|
||||
const PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
|
||||
const PROFILE_TIMEZONE_MAX_TARGETED_USERS = 1000;
|
||||
const DEFAULT_PROFILE_TIMEZONE_SALT = 'profile-timezone-v1';
|
||||
|
||||
const PROFILE_TIMEZONE_SALT_PATTERN = /^[\x20-\x7e]+$/u;
|
||||
|
||||
const ProfileTimezoneTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
|
||||
const ProfileTimezoneTargetedUserIdsSchema = z
|
||||
.array(ProfileTimezoneTargetIdSchema)
|
||||
.max(PROFILE_TIMEZONE_MAX_TARGETED_USERS);
|
||||
|
||||
const profileTimezoneConfigFields = {
|
||||
enabled: z.boolean(),
|
||||
config_version: z.number().int().min(0),
|
||||
rollout_basis_points: z.number().int().min(0).max(PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX),
|
||||
rollout_salt: z.string().trim().min(1).max(64).regex(PROFILE_TIMEZONE_SALT_PATTERN),
|
||||
included_user_ids: ProfileTimezoneTargetedUserIdsSchema,
|
||||
excluded_user_ids: ProfileTimezoneTargetedUserIdsSchema,
|
||||
};
|
||||
|
||||
export const ProfileTimezoneConfigSchema = z.object({
|
||||
enabled: profileTimezoneConfigFields.enabled.default(false),
|
||||
config_version: profileTimezoneConfigFields.config_version.default(0),
|
||||
rollout_basis_points: profileTimezoneConfigFields.rollout_basis_points.default(0),
|
||||
rollout_salt: profileTimezoneConfigFields.rollout_salt.default(DEFAULT_PROFILE_TIMEZONE_SALT),
|
||||
included_user_ids: profileTimezoneConfigFields.included_user_ids.default([]),
|
||||
excluded_user_ids: profileTimezoneConfigFields.excluded_user_ids.default([]),
|
||||
});
|
||||
|
||||
export type ProfileTimezoneConfig = z.infer<typeof ProfileTimezoneConfigSchema>;
|
||||
|
||||
export const DEFAULT_PROFILE_TIMEZONE_CONFIG: ProfileTimezoneConfig = ProfileTimezoneConfigSchema.parse({});
|
||||
|
||||
export const ProfileTimezoneConfigUpdateRequest = z
|
||||
.object(profileTimezoneConfigFields)
|
||||
.omit({config_version: true})
|
||||
.partial();
|
||||
|
||||
export type ProfileTimezoneConfigUpdateRequest = z.infer<typeof ProfileTimezoneConfigUpdateRequest>;
|
||||
|
||||
export const ProfileTimezoneConfigResponse = ProfileTimezoneConfigSchema;
|
||||
|
||||
export type ProfileTimezoneConfigResponse = z.infer<typeof ProfileTimezoneConfigResponse>;
|
||||
|
||||
export const ProfileTimezoneAssignmentResponse = z.object({
|
||||
enabled: profileTimezoneConfigFields.enabled,
|
||||
});
|
||||
|
||||
export type ProfileTimezoneAssignmentResponse = z.infer<typeof ProfileTimezoneAssignmentResponse>;
|
||||
|
||||
export const INERT_PROFILE_TIMEZONE_ASSIGNMENT: ProfileTimezoneAssignmentResponse = {
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
export function resolveProfileTimezoneAssignment(
|
||||
config: ProfileTimezoneConfig,
|
||||
userId: string,
|
||||
): ProfileTimezoneAssignmentResponse {
|
||||
if (!config.enabled) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
|
||||
if (config.excluded_user_ids.includes(userId)) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
|
||||
if (config.included_user_ids.includes(userId)) return {enabled: true};
|
||||
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
DomainMigrationAssignmentResponse,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
ProfileTimezoneAssignmentResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
VoiceNoiseSuppressionAssignmentResponse,
|
||||
@@ -49,6 +53,7 @@ const ExperimentAssignmentsSchema = z.object({
|
||||
voice_noise_suppression: VoiceNoiseSuppressionAssignmentResponse.optional(),
|
||||
domain_migration: DomainMigrationAssignmentResponse.optional(),
|
||||
altcha_captcha: AltchaCaptchaAssignmentResponse.optional(),
|
||||
profile_timezone: ProfileTimezoneAssignmentResponse.optional(),
|
||||
});
|
||||
|
||||
export const ExperimentAssignmentsResponse = z.object({
|
||||
@@ -76,3 +81,9 @@ export function readDomainMigrationAssignment(
|
||||
): DomainMigrationAssignmentResponse {
|
||||
return response.assignments.domain_migration ?? INERT_DOMAIN_MIGRATION_ASSIGNMENT;
|
||||
}
|
||||
|
||||
export function readProfileTimezoneAssignment(
|
||||
response: ExperimentAssignmentsResponse,
|
||||
): ProfileTimezoneAssignmentResponse {
|
||||
return response.assignments.profile_timezone ?? INERT_PROFILE_TIMEZONE_ASSIGNMENT;
|
||||
}
|
||||
|
||||
@@ -77,13 +77,17 @@ export const UserUpdateRequest = z
|
||||
accent_color: ColorType.nullish().describe('Profile accent color as integer'),
|
||||
timezone: createStringType(1, 128)
|
||||
.nullish()
|
||||
.describe('Staff-only IANA timezone identifier saved for profile local time. Ignored for non-staff users.'),
|
||||
.describe(
|
||||
'IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.',
|
||||
),
|
||||
timezone_privacy_flags: createBitflagInt32Type(
|
||||
ProfileFieldPrivacyFlags,
|
||||
ProfileFieldPrivacyFlagsDescriptions,
|
||||
'Bitfield controlling who can see the profile timezone',
|
||||
'ProfileFieldPrivacyFlags',
|
||||
).describe('Staff-only bitfield controlling who can see the profile timezone. Ignored for non-staff users.'),
|
||||
).describe(
|
||||
'Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.',
|
||||
),
|
||||
premium_badge_hidden: z.boolean().describe('Whether to hide the premium badge'),
|
||||
premium_badge_masked: z.boolean().describe('Whether to mask the premium badge'),
|
||||
premium_badge_timestamp_hidden: z.boolean().describe('Whether to hide premium badge timestamp'),
|
||||
|
||||
@@ -90,11 +90,7 @@ export const UserPrivateResponse = UserPartialResponse.extend({
|
||||
bio: z.string().nullable().describe('The user biography text'),
|
||||
pronouns: z.string().nullable().describe('The preferred pronouns of the user'),
|
||||
accent_color: Int32Type.nullable().describe('The user-selected accent color as an integer'),
|
||||
timezone: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('The IANA timezone identifier saved by the user. Omitted unless the user has staff access.'),
|
||||
timezone: z.string().nullable().optional().describe('The IANA timezone identifier saved by the user'),
|
||||
timezone_privacy_flags: createBitflagInt32Type(
|
||||
ProfileFieldPrivacyFlags,
|
||||
ProfileFieldPrivacyFlagsDescriptions,
|
||||
@@ -102,7 +98,7 @@ export const UserPrivateResponse = UserPartialResponse.extend({
|
||||
'ProfileFieldPrivacyFlags',
|
||||
)
|
||||
.optional()
|
||||
.describe('Bitfield controlling who can see the profile timezone. Omitted unless the user has staff access.'),
|
||||
.describe('Bitfield controlling who can see the profile timezone'),
|
||||
banner: z.string().nullable().describe('The hash of the user profile banner image'),
|
||||
banner_color: Int32Type.nullable().describe('The default banner color if no custom banner is set'),
|
||||
mfa_enabled: z.boolean().describe('Whether multi-factor authentication is enabled'),
|
||||
|
||||
Reference in New Issue
Block a user