feat(profile): ship profile timezone to everyone (#3034)

This commit is contained in:
Hampus
2026-09-29 16:28:40 +02:00
committed by GitHub
parent b30ea361d3
commit d433a039b5
28 changed files with 71 additions and 971 deletions
@@ -38,7 +38,6 @@ import {
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -69,7 +68,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
pushRelay,
domainMigration,
altchaCaptcha,
profileTimezone,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -80,7 +78,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -115,7 +112,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
profile_timezone: profileTimezone,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -404,18 +400,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.profile_timezone) {
const patch = omitUndefinedFields(data.profile_timezone);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
ProfileTimezoneConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.experiment_delivery) {
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
@@ -11,7 +11,6 @@ import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
export function ExperimentController(app: HonoApp) {
@@ -31,26 +30,20 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] = await Promise.all([
const [delivery, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
]);
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [
domainMigrationConfig,
altchaCaptchaConfig,
profileTimezoneConfig,
]);
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig, altchaCaptchaConfig]);
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -17,10 +17,6 @@ import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_PROFILE_TIMEZONE_CONFIG,
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
@@ -63,7 +59,6 @@ describe('GET /experiments', () => {
assignments: {
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
},
});
});
@@ -170,56 +165,6 @@ describe('GET /experiments', () => {
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
});
it('resolves the profile timezone caller through the allowlist and the exclusion list', async () => {
const targeted = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
rollout_basis_points: 10000,
included_user_ids: [targeted.userId],
excluded_user_ids: [excluded.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.profile_timezone).toEqual({enabled: true});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.profile_timezone).toEqual({enabled: false});
});
it('bumps the profile timezone config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{profile_timezone: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({profile_timezone: {enabled: true, included_user_ids: [admin.userId]}})
.execute();
expect(afterFirst.profile_timezone).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{
profile_timezone: {config_version: number; rollout_basis_points: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({profile_timezone: {rollout_basis_points: 2500}})
.execute();
expect(afterSecond.profile_timezone).toMatchObject({config_version: 2, rollout_basis_points: 2500});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.profile_timezone).toEqual({enabled: true});
});
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
@@ -239,25 +184,18 @@ describe('GET /experiments', () => {
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
.get(ENDPOINT)
.execute();
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
.get(ENDPOINT)
.execute();
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
});
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
@@ -266,8 +204,8 @@ describe('GET /experiments', () => {
const free = await createTestAccount(harness);
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
include_premium_users: true,
});
@@ -277,7 +215,7 @@ describe('GET /experiments', () => {
[free, false],
] as const) {
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
expect(body.assignments.domain_migration).toEqual({enabled: expected});
}
});
@@ -289,22 +227,17 @@ describe('GET /experiments', () => {
]);
const guildIds = ['1500000000000000001', '1500000000000000002'];
const body = await createBuilder<
Record<
'domain_migration' | 'altcha_captcha' | 'profile_timezone',
{included_guild_ids: Array<string>; include_premium_users: boolean}
>
Record<'domain_migration' | 'altcha_captcha', {included_guild_ids: Array<string>; include_premium_users: boolean}>
>(harness, admin.token)
.patch('/admin/instance/config')
.body({
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
profile_timezone: {included_guild_ids: guildIds, include_premium_users: true},
})
.execute();
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
expect(body.profile_timezone.included_guild_ids).toEqual(guildIds);
for (const section of [body.domain_migration, body.altcha_captcha, body.profile_timezone]) {
for (const section of [body.domain_migration, body.altcha_captcha]) {
expect(section.include_premium_users).toBe(true);
}
});
@@ -50,10 +50,6 @@ import {
type StoredBillingConfig,
StoredBillingConfigSchema,
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
import {
type ProfileTimezoneConfig,
ProfileTimezoneConfigSchema,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
type LegacyPushServiceDeliveryWire,
type PushRelayConfig,
@@ -84,7 +80,6 @@ const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
const PROFILE_TIMEZONE_CONFIG_KEY = 'profile_timezone_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -441,7 +436,6 @@ type StoredConfigSection =
| 'push relay'
| 'domain migration'
| 'altcha captcha'
| 'profile timezone'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -606,10 +600,6 @@ function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
}
function parseStoredProfileTimezoneConfig(raw: string | null): ProfileTimezoneConfig {
return parseStoredConfigOrDefault(ProfileTimezoneConfigSchema, raw, 'profile timezone');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
}
@@ -1281,7 +1271,6 @@ export class InstanceConfigRepository {
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
parseStoredProfileTimezoneConfig(snapshot.get(PROFILE_TIMEZONE_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1412,27 +1401,6 @@ export class InstanceConfigRepository {
);
}
async getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
return parseStoredProfileTimezoneConfig(raw);
}
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
await this.updateProfileTimezoneConfig(() => config);
}
updateProfileTimezoneConfig(
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
): Promise<ProfileTimezoneConfig> {
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
validateStoredConfig(
ProfileTimezoneConfigSchema,
update(parseStoredProfileTimezoneConfig(raw)),
'profile timezone',
),
);
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
return parseStoredExperimentDeliveryConfig(raw);
+3 -13
View File
@@ -25109,13 +25109,10 @@
"anyOf": [{"$ref": "#/components/schemas/ColorType"}, {"type": "null"}]
},
"timezone": {
"description": "IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.",
"description": "IANA timezone identifier saved for profile local time",
"type": ["string", "null"]
},
"timezone_privacy_flags": {
"description": "Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.",
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
},
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
"premium_badge_hidden": {"type": "boolean", "description": "Whether to hide the premium badge"},
"premium_badge_masked": {"type": "boolean", "description": "Whether to mask the premium badge"},
"premium_badge_timestamp_hidden": {
@@ -27944,8 +27941,7 @@
"type": "object",
"properties": {
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneAssignmentResponse"}
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
},
"additionalProperties": false
}
@@ -31644,12 +31640,6 @@
"additionalProperties": false
},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
"ProfileTimezoneAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
"required": ["enabled"],
"additionalProperties": false
},
"AltchaCaptchaAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
-9
View File
@@ -2,10 +2,8 @@
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {sharedListHas} from '@app/api/infrastructure/activity/SharedLists';
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
import {
@@ -15,7 +13,6 @@ import {
SuspiciousActivityFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {ms} from 'itty-time';
@@ -315,9 +312,3 @@ export function isProfileSubstringExempt(user: Pick<PremiumCheckable, 'flags'>):
export function isBugHunterBotUser(user: Pick<User, 'flags' | 'isBot'>): boolean {
return user.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
}
export async function canUseProfileTimezone(user: User): Promise<boolean> {
const config = await getInstanceConfigRepository().getProfileTimezoneConfig();
const targeting = await resolveExperimentTargeting(user, [config]);
return resolveProfileTimezoneAssignment(config, user.id.toString(), targeting).enabled;
}
@@ -13,7 +13,6 @@ import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccou
import type {IUserChannelRepository} from '@app/api/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@app/api/user/repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '@app/api/user/repositories/IUserSettingsRepository';
import {canUseProfileTimezone} from '@app/api/user/UserHelpers';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {
PremiumFlags,
@@ -129,8 +128,7 @@ export class UserAccountLookupService {
!restrictProfile &&
user.timezone != null &&
profileFieldPrivacyContext != null &&
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext) &&
(await canUseProfileTimezone(user));
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext);
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
@@ -10,7 +10,7 @@ import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import type {User} from '@app/api/models/User';
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
import {canUseProfileTimezone, isProfileSubstringExempt} from '@app/api/user/UserHelpers';
import {isProfileSubstringExempt} from '@app/api/user/UserHelpers';
import {deriveDominantAvatarColor} from '@app/api/utils/AvatarColorUtils';
import * as EmojiUtils from '@app/api/utils/EmojiUtils';
import {MAX_BIO_LENGTH} from '@fluxer/constants/src/LimitConstants';
@@ -83,15 +83,13 @@ export class UserAccountProfileService {
if (data.accent_color !== undefined) {
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
}
const canUpdateProfileTimezone =
(data.timezone !== undefined || data.timezone_privacy_flags !== undefined) && (await canUseProfileTimezone(user));
if (canUpdateProfileTimezone && data.timezone !== undefined) {
if (data.timezone !== undefined) {
const nextTimezone = this.processTimezoneUpdate({user, timezone: data.timezone, updates});
if (nextTimezone !== null && user.timezone === null && data.timezone_privacy_flags === undefined) {
updates.timezone_privacy_flags = ProfileFieldPrivacyFlags.EVERYONE;
}
}
if (canUpdateProfileTimezone && data.timezone_privacy_flags !== undefined) {
if (data.timezone_privacy_flags !== undefined) {
this.processTimezonePrivacyFlagsUpdate({
user,
privacyFlags: data.timezone_privacy_flags,
@@ -19,7 +19,6 @@ import type {UserAccountService} from '@app/api/user/services/UserAccountService
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
import {mapUserToPartialResponseWithCache} from '@app/api/user/UserCacheHelpers';
import {
canUseProfileTimezone,
createPremiumClearPatch,
getEffectiveSuspiciousFlags,
shouldStripExpiredPremium,
@@ -69,20 +68,6 @@ function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
return EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS.some((field) => data[field] !== undefined);
}
async function stripUnauthorizedProfileTimezoneUpdate(
user: User,
body: UserUpdateWithVerificationRequest,
): Promise<UserUpdateWithVerificationRequest> {
if (body.timezone === undefined && body.timezone_privacy_flags === undefined) {
return body;
}
if (await canUseProfileTimezone(user)) {
return body;
}
const {timezone: _timezone, timezone_privacy_flags: _timezonePrivacyFlags, ...rest} = body;
return rest;
}
function hasDefinedUserUpdatePayload(data: UserUpdatePayload): boolean {
return Object.values(data).some((value) => value !== undefined);
}
@@ -158,7 +143,6 @@ export class UserAccountRequestService {
const {ctx, body, authSession} = params;
const {user} = params;
const oldEmail = user.email;
const sanitizedBody = await stripUnauthorizedProfileTimezoneUpdate(user, body);
const {
mfa_method: _mfaMethod,
mfa_code: _mfaCode,
@@ -166,13 +150,13 @@ export class UserAccountRequestService {
webauthn_challenge: _webauthnChallenge,
email_token: emailToken,
...userUpdateDataRest
} = sanitizedBody;
} = body;
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
const emailTokenProvided = emailToken !== undefined;
if (!emailTokenProvided && !hasDefinedUserUpdatePayload(userUpdateData)) {
return mapUserToPrivateResponse(user);
}
this.enforceSuspiciousSelfUpdateAllowance(user, sanitizedBody);
this.enforceSuspiciousSelfUpdateAllowance(user, body);
if (userUpdateData.email !== undefined) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_MUST_BE_CHANGED_VIA_TOKEN);
}
@@ -198,7 +182,7 @@ export class UserAccountRequestService {
const needsVerification = this.requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
let sudoResult: SudoVerificationResult | null = null;
if (needsVerification) {
sudoResult = await requireSudoMode(ctx, user, sanitizedBody);
sudoResult = await requireSudoMode(ctx, user, body);
}
if (emailTokenProvided && emailToken) {
emailFromToken = await this.emailChangeService.getTokenEmail(user.id, emailToken);
@@ -3,7 +3,6 @@
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
@@ -12,7 +11,6 @@ import {
ProfilePrivacyLevels,
} from '@fluxer/constants/src/UserConstants';
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
import {DEFAULT_PROFILE_TIMEZONE_CONFIG} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -31,14 +29,6 @@ async function updateProfileTimezone(
return createBuilder<UserPrivateResponse>(harness, token).patch('/users/@me').body(data).execute();
}
async function setProfileTimezoneUsers(userIds: Array<string>): Promise<void> {
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_user_ids: userIds,
});
}
async function updateProfilePrivacy(
harness: ApiTestHarness,
token: string,
@@ -76,7 +66,6 @@ describe('User Profile Timezone Visibility', () => {
it('defaults timezone visibility to everyone when a timezone is set', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBe(TEST_TIMEZONE);
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
@@ -86,7 +75,6 @@ describe('User Profile Timezone Visibility', () => {
});
it('restores default timezone visibility when a timezone is set again without explicit flags', async () => {
const targetAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: 0,
@@ -97,7 +85,6 @@ describe('User Profile Timezone Visibility', () => {
});
it('hides timezone from the public profile when privacy flags are unset', async () => {
const targetAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: 0,
@@ -109,7 +96,6 @@ describe('User Profile Timezone Visibility', () => {
const targetAccount = await createTestAccount(harness);
const friendAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
@@ -125,7 +111,6 @@ describe('User Profile Timezone Visibility', () => {
const targetAccount = await createTestAccount(harness);
const friendAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.MUTUAL_GUILDS,
@@ -140,7 +125,6 @@ describe('User Profile Timezone Visibility', () => {
it('hides timezone when full profile privacy restricts the viewer', async () => {
const targetAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
await updateProfilePrivacy(harness, targetAccount.token, ProfilePrivacyLevels.FRIENDS_ONLY);
await createSharedGuild(harness, targetAccount.token, guildMemberAccount.token);
@@ -148,49 +132,4 @@ describe('User Profile Timezone Visibility', () => {
expect(profile.profile_limited).toBe(true);
expect(profile.timezone_offset).toBeNull();
});
it('ignores profile timezone updates from users outside the experiment', async () => {
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
const updated = await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
});
expect(updated.timezone).toBeNull();
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
});
it('ignores profile timezone updates from users excluded from a full rollout', async () => {
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
rollout_basis_points: 10000,
excluded_user_ids: [targetAccount.userId],
});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBeNull();
});
it('lets members of an included guild set and show a timezone', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
const guild = await createGuild(harness, targetAccount.token, 'Timezone Rollout Guild');
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBe(TEST_TIMEZONE);
await createFriendship(harness, targetAccount, viewerAccount);
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
expect(profile.timezone_offset).toBe(TEST_TIMEZONE_OFFSET);
});
it('hides stored profile timezone after the user leaves the experiment', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
await setProfileTimezoneUsers([]);
await createFriendship(harness, targetAccount, viewerAccount);
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
expect(profile.timezone_offset).toBeNull();
});
});