From f906ad6ca7044e4c509a5fe01f398f841a44027a Mon Sep 17 00:00:00 2001
From: zawa-ch <satellite.2e1834097@gmail.com>
Date: Tue, 27 Feb 2024 18:45:46 +0900
Subject: [PATCH] =?UTF-8?q?Enhance:=20=E3=82=B3=E3=83=B3=E3=83=87=E3=82=A3?=
 =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=8A=E3=83=AB=E3=83=AD=E3=83=BC=E3=83=AB?=
 =?UTF-8?q?=E3=81=AE=E6=9D=A1=E4=BB=B6=E3=81=AB=E3=80=8C=E3=83=9E=E3=83=8B?=
 =?UTF-8?q?=E3=83=A5=E3=82=A2=E3=83=AB=E3=83=AD=E3=83=BC=E3=83=AB=E3=81=B8?=
 =?UTF-8?q?=E3=81=AE=E3=82=A2=E3=82=B5=E3=82=A4=E3=83=B3=E3=80=8D=E3=82=92?=
 =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20(#13463)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* コンディショナルロールの条件に「マニュアルロールへのアサイン」を追加

* コメント修正
---
 CHANGELOG.md                                  |  1 +
 locales/index.d.ts                            |  4 +++
 locales/ja-JP.yml                             |  1 +
 packages/backend/src/core/RoleService.ts      | 19 +++++++------
 packages/backend/src/misc/json-schema.ts      |  2 ++
 packages/backend/src/models/Role.ts           |  6 ++++
 .../backend/src/models/json-schema/role.ts    | 20 +++++++++++++
 packages/backend/test/unit/RoleService.ts     | 28 +++++++++++++++++++
 .../src/pages/admin/RolesEditorFormula.vue    |  9 ++++++
 packages/misskey-js/etc/misskey-js.api.md     |  4 +++
 packages/misskey-js/src/autogen/models.ts     |  1 +
 packages/misskey-js/src/autogen/types.ts      | 11 +++++++-
 12 files changed, 97 insertions(+), 9 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebbe22f2f6..513338e667 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@
 
 ### General
 - Enhance: サーバーごとにモデレーションノートを残せるように
+- Enhance: コンディショナルロールの条件に「マニュアルロールへのアサイン」を追加
 
 ### Client
 - Enhance: ノート作成画面のファイル添付メニューの区切り線の位置を調整
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 1a2565b06a..7d5f8ce732 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -6528,6 +6528,10 @@ export interface Locale extends ILocale {
             "avatarDecorationLimit": string;
         };
         "_condition": {
+            /**
+             * マニュアルロールにアサイン済み
+             */
+            "roleAssignedTo": string;
             /**
              * ローカルユーザー
              */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 61c61b8f96..1bb56738c6 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1687,6 +1687,7 @@ _role:
     canUseTranslator: "翻訳機能の利用"
     avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
   _condition:
+    roleAssignedTo: "マニュアルロールにアサイン済み"
     isLocal: "ローカルユーザー"
     isRemote: "リモートユーザー"
     createdLessThan: "アカウント作成から~以内"
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index c5baaf3fff..8312489a78 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -200,17 +200,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 	}
 
 	@bindThis
-	private evalCond(user: MiUser, value: RoleCondFormulaValue): boolean {
+	private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
 		try {
 			switch (value.type) {
 				case 'and': {
-					return value.values.every(v => this.evalCond(user, v));
+					return value.values.every(v => this.evalCond(user, roles, v));
 				}
 				case 'or': {
-					return value.values.some(v => this.evalCond(user, v));
+					return value.values.some(v => this.evalCond(user, roles, v));
 				}
 				case 'not': {
-					return !this.evalCond(user, value.value);
+					return !this.evalCond(user, roles, value.value);
+				}
+				case 'roleAssignedTo': {
+					return roles.some(r => r.id === value.roleId);
 				}
 				case 'isLocal': {
 					return this.userEntityService.isLocalUser(user);
@@ -272,7 +275,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 		const assigns = await this.getUserAssigns(userId);
 		const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
 		const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
-		const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
+		const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
 		return [...assignedRoles, ...matchedCondRoles];
 	}
 
@@ -285,13 +288,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 		let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
 		// 期限切れのロールを除外
 		assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
-		const assignedRoleIds = assigns.map(x => x.roleId);
 		const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
-		const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
+		const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
+		const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
 		const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
 		if (badgeCondRoles.length > 0) {
 			const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
-			const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula));
+			const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
 			return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
 		} else {
 			return assignedBadgeRoles;
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 8449e5ff07..46b0bb2fab 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -44,6 +44,7 @@ import {
 	packedRoleCondFormulaLogicsSchema,
 	packedRoleCondFormulaValueNot,
 	packedRoleCondFormulaValueIsLocalOrRemoteSchema,
+	packedRoleCondFormulaValueAssignedRoleSchema,
 	packedRoleCondFormulaValueCreatedSchema,
 	packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
 	packedRoleCondFormulaValueSchema,
@@ -96,6 +97,7 @@ export const refs = {
 	RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
 	RoleCondFormulaValueNot: packedRoleCondFormulaValueNot,
 	RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema,
+	RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema,
 	RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema,
 	RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
 	RoleCondFormulaValue: packedRoleCondFormulaValueSchema,
diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts
index fa05ea8637..058abe3118 100644
--- a/packages/backend/src/models/Role.ts
+++ b/packages/backend/src/models/Role.ts
@@ -29,6 +29,11 @@ type CondFormulaValueIsRemote = {
 	type: 'isRemote';
 };
 
+type CondFormulaValueRoleAssignedTo = {
+	type: 'roleAssignedTo';
+	roleId: string;
+};
+
 type CondFormulaValueCreatedLessThan = {
 	type: 'createdLessThan';
 	sec: number;
@@ -75,6 +80,7 @@ export type RoleCondFormulaValue = { id: string } & (
 	CondFormulaValueNot |
 	CondFormulaValueIsLocal |
 	CondFormulaValueIsRemote |
+	CondFormulaValueRoleAssignedTo |
 	CondFormulaValueCreatedLessThan |
 	CondFormulaValueCreatedMoreThan |
 	CondFormulaValueFollowersLessThanOrEq |
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index ef6b279bee..9f2b5b17ed 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -57,6 +57,23 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = {
 	},
 } as const;
 
+export const packedRoleCondFormulaValueAssignedRoleSchema = {
+	type: 'object',
+	properties: {
+		type: {
+			type: 'string',
+			nullable: false, optional: false,
+			enum: ['roleAssignedTo'],
+		},
+		roleId: {
+			type: 'string',
+			nullable: false, optional: false,
+			format: 'id',
+			example: 'xxxxxxxxxx',
+		},
+	},
+} as const;
+
 export const packedRoleCondFormulaValueCreatedSchema = {
 	type: 'object',
 	properties: {
@@ -115,6 +132,9 @@ export const packedRoleCondFormulaValueSchema = {
 		{
 			ref: 'RoleCondFormulaValueIsLocalOrRemote',
 		},
+		{
+			ref: 'RoleCondFormulaValueAssignedRole',
+		},
 		{
 			ref: 'RoleCondFormulaValueCreated',
 		},
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index 5222745b7f..fe5ad31597 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -251,6 +251,34 @@ describe('RoleService', () => {
 			expect(user2Policies.canManageCustomEmojis).toBe(true);
 		});
 
+		test('コンディショナルロール: マニュアルロールにアサイン済み', async () => {
+			const [user1, user2, role1] = await Promise.all([
+				createUser(),
+				createUser(),
+				createRole({
+					name: 'manual role',
+				}),
+			]);
+			const role2 = await createRole({
+				name: 'conditional role',
+				target: 'conditional',
+				condFormula: {
+					// idはバックエンドのロジックに必要ない?
+					id: 'bdc612bd-9d54-4675-ae83-0499c82ea670',
+					type: 'roleAssignedTo',
+					roleId: role1.id,
+				},
+			});
+			await roleService.assign(user2.id, role1.id);
+
+			const [u1role, u2role] = await Promise.all([
+				roleService.getUserRoles(user1.id),
+				roleService.getUserRoles(user2.id),
+			]);
+			expect(u1role.some(r => r.id === role2.id)).toBe(false);
+			expect(u2role.some(r => r.id === role2.id)).toBe(true);
+		});
+
 		test('expired role', async () => {
 			const user = await createUser();
 			const role = await createRole({
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
index f4a8f44955..2f5b4c47d8 100644
--- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkSelect v-model="type" :class="$style.typeSelect">
 			<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
 			<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
+			<option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option>
 			<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
 			<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>
 			<option value="followersLessThanOrEq">{{ i18n.ts._role._condition.followersLessThanOrEq }}</option>
@@ -51,6 +52,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 	<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
 	</MkInput>
+
+	<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
+		<option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option>
+	</MkSelect>
 </div>
 </template>
 
@@ -62,6 +67,7 @@ import MkSelect from '@/components/MkSelect.vue';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { deepClone } from '@/scripts/clone.js';
+import { rolesCache } from '@/cache.js';
 
 const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
 
@@ -77,6 +83,8 @@ const props = defineProps<{
 
 const v = ref(deepClone(props.modelValue));
 
+const roles = await rolesCache.fetch();
+
 watch(() => props.modelValue, () => {
 	if (JSON.stringify(props.modelValue) === JSON.stringify(v.value)) return;
 	v.value = deepClone(props.modelValue);
@@ -92,6 +100,7 @@ const type = computed({
 		if (t === 'and') v.value.values = [];
 		if (t === 'or') v.value.values = [];
 		if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' };
+		if (t === 'roleAssignedTo') v.value.roleId = '';
 		if (t === 'createdLessThan') v.value.sec = 86400;
 		if (t === 'createdMoreThan') v.value.sec = 86400;
 		if (t === 'followersLessThanOrEq') v.value.value = 10;
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index b5e7ec7548..0e990ffd5a 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1712,6 +1712,7 @@ declare namespace entities {
         RoleCondFormulaLogics,
         RoleCondFormulaValueNot,
         RoleCondFormulaValueIsLocalOrRemote,
+        RoleCondFormulaValueAssignedRole,
         RoleCondFormulaValueCreated,
         RoleCondFormulaFollowersOrFollowingOrNotes,
         RoleCondFormulaValue,
@@ -2731,6 +2732,9 @@ type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics'];
 // @public (undocumented)
 type RoleCondFormulaValue = components['schemas']['RoleCondFormulaValue'];
 
+// @public (undocumented)
+type RoleCondFormulaValueAssignedRole = components['schemas']['RoleCondFormulaValueAssignedRole'];
+
 // @public (undocumented)
 type RoleCondFormulaValueCreated = components['schemas']['RoleCondFormulaValueCreated'];
 
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index ab49f9478a..6f61458600 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -38,6 +38,7 @@ export type Signin = components['schemas']['Signin'];
 export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics'];
 export type RoleCondFormulaValueNot = components['schemas']['RoleCondFormulaValueNot'];
 export type RoleCondFormulaValueIsLocalOrRemote = components['schemas']['RoleCondFormulaValueIsLocalOrRemote'];
+export type RoleCondFormulaValueAssignedRole = components['schemas']['RoleCondFormulaValueAssignedRole'];
 export type RoleCondFormulaValueCreated = components['schemas']['RoleCondFormulaValueCreated'];
 export type RoleCondFormulaFollowersOrFollowingOrNotes = components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
 export type RoleCondFormulaValue = components['schemas']['RoleCondFormulaValue'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 733670d704..8d700fb828 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4573,6 +4573,15 @@ export type components = {
       /** @enum {string} */
       type: 'isLocal' | 'isRemote';
     };
+    RoleCondFormulaValueAssignedRole: {
+      /** @enum {string} */
+      type: 'roleAssignedTo';
+      /**
+       * Format: id
+       * @example xxxxxxxxxx
+       */
+      roleId: string;
+    };
     RoleCondFormulaValueCreated: {
       id: string;
       /** @enum {string} */
@@ -4585,7 +4594,7 @@ export type components = {
       type: 'followersLessThanOrEq' | 'followersMoreThanOrEq' | 'followingLessThanOrEq' | 'followingMoreThanOrEq' | 'notesLessThanOrEq' | 'notesMoreThanOrEq';
       value: number;
     };
-    RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
+    RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueAssignedRole'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
     RoleLite: {
       /**
        * Format: id