From 10928182034f1db668de422cda6461bd31aaaa73 Mon Sep 17 00:00:00 2001
From: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Date: Wed, 22 May 2019 05:06:52 +0900
Subject: [PATCH 1/6] Add group update / transfer API

---
 locales/ja-JP.yml                             |  3 +
 .../common/views/pages/user-group-editor.vue  | 81 +++++++++++++++--
 src/client/app/init.ts                        |  2 +
 src/client/themes/dark.json5                  |  3 +
 src/client/themes/light.json5                 |  3 +
 src/models/repositories/user-group.ts         |  6 ++
 .../api/endpoints/users/groups/transfer.ts    | 86 +++++++++++++++++++
 .../api/endpoints/users/groups/update.ts      | 62 +++++++++++++
 8 files changed, 240 insertions(+), 6 deletions(-)
 create mode 100644 src/server/api/endpoints/users/groups/transfer.ts
 create mode 100644 src/server/api/endpoints/users/groups/update.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 9047378849..c3eb0bb523 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -762,6 +762,9 @@ common/views/components/user-group-editor.vue:
   users: "メンバー"
   rename: "グループ名を変更"
   delete: "グループを削除"
+  transfer: "グループを譲渡"
+  transfer-are-you-sure: "グループ「$1」を「@$2」さんに譲渡しますか?"
+  transferred: "グループを譲渡しました"
   remove-user: "このグループから削除"
   delete-are-you-sure: "グループ「$1」を削除しますか?"
   deleted: "削除しました"
diff --git a/src/client/app/common/views/pages/user-group-editor.vue b/src/client/app/common/views/pages/user-group-editor.vue
index ef79689ae8..a32148cd7f 100644
--- a/src/client/app/common/views/pages/user-group-editor.vue
+++ b/src/client/app/common/views/pages/user-group-editor.vue
@@ -7,6 +7,7 @@
 			<ui-margin>
 				<ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button>
 				<ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button>
+				<ui-button @click="transfer"><fa :icon="faCrown"/> {{ $t('transfer') }}</ui-button>
 			</ui-margin>
 		</section>
 	</ui-container>
@@ -28,9 +29,10 @@
 					<div>
 						<header>
 							<b><mk-user-name :user="user"/></b>
+							<span class="is-owner" v-if="group.owner === user.id">owner</span>
 							<span class="username">@{{ user | acct }}</span>
 						</header>
-						<div>
+						<div v-if="group.owner !== user.id">
 							<a @click="remove(user)">{{ $t('remove-user') }}</a>
 						</div>
 					</div>
@@ -44,7 +46,7 @@
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
-import { faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons';
+import { faCrown, faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons';
 import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
 
 export default Vue.extend({
@@ -60,7 +62,7 @@ export default Vue.extend({
 		return {
 			group: null,
 			users: [],
-			faICursor, faTrashAlt, faUsers, faPlus
+			faCrown, faICursor, faTrashAlt, faUsers, faPlus
 		};
 	},
 
@@ -78,6 +80,14 @@ export default Vue.extend({
 	},
 
 	methods: {
+		fetchGroup() {
+			this.$root.api('users/groups/show', {
+				groupId: this.group.id
+			}).then(group => {
+				this.group = group;
+			})
+		},
+
 		fetchUsers() {
 			this.$root.api('users/show', {
 				userIds: this.group.userIds
@@ -97,8 +107,15 @@ export default Vue.extend({
 				this.$root.api('users/groups/update', {
 					groupId: this.group.id,
 					name: name
+				}).then(() => {
+					this.fetchGroup();
+				}).catch(e => {
+					this.$root.dialog({
+						type: 'error',
+						text: e
+					});
 				});
-			});
+			})
 		},
 
 		del() {
@@ -130,12 +147,17 @@ export default Vue.extend({
 				groupId: this.group.id,
 				userId: user.id
 			}).then(() => {
+				this.fetchGroup();
 				this.fetchUsers();
+			}).catch(e => {
+				this.$root.dialog({
+					type: 'error',
+					text: e
+				});
 			});
 		},
 
 		async invite() {
-			const t = this.$t('invited');
 			const { result: user } = await this.$root.dialog({
 				user: {
 					local: true
@@ -148,7 +170,44 @@ export default Vue.extend({
 			}).then(() => {
 				this.$root.dialog({
 					type: 'success',
-					text: t
+					text: this.$t('invited')
+				});
+			}).catch(e => {
+				this.$root.dialog({
+					type: 'error',
+					text: e
+				});
+			});
+		},
+
+		async transfer() {
+			const { result: user } = await this.$root.dialog({
+				user: {
+					local: true
+				}
+			});
+			if (user == null) return;
+
+			this.$root.dialog({
+				type: 'warning',
+				text: this.$t('transfer-are-you-sure').replace('$1', this.group.name).replace('$2', user.username),
+				showCancelButton: true
+			}).then(({ canceled }) => {
+				if (canceled) return;
+
+				this.$root.api('users/groups/transfer', {
+					groupId: this.group.id,
+					userId: user.id
+				}).then(() => {
+					this.$root.dialog({
+						type: 'success',
+						text: this.$t('transferred')
+					});
+				}).catch(e => {
+					this.$root.dialog({
+						type: 'error',
+						text: e
+					});
 				});
 			});
 		}
@@ -179,6 +238,16 @@ export default Vue.extend({
 			> header
 				color var(--text)
 
+				> .is-owner
+					flex-shrink 0
+					align-self center
+					margin-left 8px
+					padding 1px 6px
+					font-size 80%
+					background var(--groupUserListOwnerBg)
+					color var(--groupUserListOwnerFg)
+					border-radius 3px
+
 				> .username
 					margin-left 8px
 					opacity 0.7
diff --git a/src/client/app/init.ts b/src/client/app/init.ts
index da7baff4fe..52da380e84 100644
--- a/src/client/app/init.ts
+++ b/src/client/app/init.ts
@@ -78,6 +78,7 @@ import {
 	faKey,
 	faBan,
 	faCogs,
+	faCrown,
 	faUnlockAlt,
 	faPuzzlePiece,
 	faMobileAlt,
@@ -210,6 +211,7 @@ library.add(
 	faKey,
 	faBan,
 	faCogs,
+	faCrown,
 	faUnlockAlt,
 	faPuzzlePiece,
 	faMobileAlt,
diff --git a/src/client/themes/dark.json5 b/src/client/themes/dark.json5
index 8e0c726b4c..0665d59901 100644
--- a/src/client/themes/dark.json5
+++ b/src/client/themes/dark.json5
@@ -235,5 +235,8 @@
 
 		pageBlockBorder: 'rgba(255, 255, 255, 0.1)',
 		pageBlockBorderHover: 'rgba(255, 255, 255, 0.15)',
+
+		groupUserListOwnerFg: '#f15f71',
+		groupUserListOwnerBg: '#5d282e'
 	},
 }
diff --git a/src/client/themes/light.json5 b/src/client/themes/light.json5
index 1fff18176a..cbe456ca5d 100644
--- a/src/client/themes/light.json5
+++ b/src/client/themes/light.json5
@@ -235,5 +235,8 @@
 
 		pageBlockBorder: 'rgba(0, 0, 0, 0.1)',
 		pageBlockBorderHover: 'rgba(0, 0, 0, 0.15)',
+
+		groupUserListOwnerFg: '#f15f71',
+		groupUserListOwnerBg: '#ffdfdf'
 	},
 }
diff --git a/src/models/repositories/user-group.ts b/src/models/repositories/user-group.ts
index 8bb1ae8330..dbbe8bf84c 100644
--- a/src/models/repositories/user-group.ts
+++ b/src/models/repositories/user-group.ts
@@ -21,6 +21,7 @@ export class UserGroupRepository extends Repository<UserGroup> {
 			id: userGroup.id,
 			createdAt: userGroup.createdAt.toISOString(),
 			name: userGroup.name,
+			owner: userGroup.userId,
 			userIds: users.map(x => x.userId)
 		};
 	}
@@ -48,6 +49,11 @@ export const packedUserGroupSchema = {
 			optional: bool.false, nullable: bool.false,
 			description: 'The name of the UserGroup.'
 		},
+		owner: {
+			type: types.string,
+			nullable: bool.false, optional: bool.false,
+			format: 'id',
+		},
 		userIds: {
 			type: types.array,
 			nullable: bool.false, optional: bool.true,
diff --git a/src/server/api/endpoints/users/groups/transfer.ts b/src/server/api/endpoints/users/groups/transfer.ts
new file mode 100644
index 0000000000..3baa182abf
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/transfer.ts
@@ -0,0 +1,86 @@
+import $ from 'cafy';
+import { ID } from '../../../../../misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getUser } from '../../../common/getters';
+import { UserGroups, UserGroupJoinings } from '../../../../../models';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーグループを指定したユーザーグループ内のユーザーに譲渡します。',
+		'en-US': 'Transfer user group ownership to another user in group.'
+	},
+
+	tags: ['groups', 'users'],
+
+	requireCredential: true,
+
+	kind: 'write:user-groups',
+
+	params: {
+		groupId: {
+			validator: $.type(ID),
+		},
+
+		userId: {
+			validator: $.type(ID),
+			desc: {
+				'ja-JP': '対象のユーザーのID',
+				'en-US': 'Target user ID'
+			}
+		},
+	},
+
+	errors: {
+		noSuchGroup: {
+			message: 'No such group.',
+			code: 'NO_SUCH_GROUP',
+			id: '8e31d36b-2f88-4ccd-a438-e2d78a9162db'
+		},
+
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: '711f7ebb-bbb9-4dfa-b540-b27809fed5e9'
+		},
+
+		noSuchGroupMember: {
+			message: 'No such group member.',
+			code: 'NO_SUCH_GROUP_MEMBER',
+			id: 'd31bebee-196d-42c2-9a3e-9474d4be6cc4'
+		},
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	// Fetch the group
+	const userGroup = await UserGroups.findOne({
+		id: ps.groupId,
+		userId: me.id,
+	});
+
+	if (userGroup == null) {
+		throw new ApiError(meta.errors.noSuchGroup);
+	}
+
+	// Fetch the user
+	const user = await getUser(ps.userId).catch(e => {
+		if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+		throw e;
+	});
+
+	const joining = await UserGroupJoinings.findOne({
+		userGroupId: userGroup.id,
+		userId: user.id
+	});
+
+	if (!joining) {
+		throw new ApiError(meta.errors.noSuchGroupMember);
+	}
+
+	await UserGroups.update(userGroup.id, {
+		userId: ps.userId
+	});
+
+	return await UserGroups.pack(userGroup.id);
+});
diff --git a/src/server/api/endpoints/users/groups/update.ts b/src/server/api/endpoints/users/groups/update.ts
new file mode 100644
index 0000000000..ad9a1faa23
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/update.ts
@@ -0,0 +1,62 @@
+import $ from 'cafy';
+import { ID } from '../../../../../misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserGroups } from '../../../../../models';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーグループを更新します。',
+		'en-US': 'Update a user group'
+	},
+
+	tags: ['groups'],
+
+	requireCredential: true,
+
+	kind: 'write:user-groups',
+
+	params: {
+		groupId: {
+			validator: $.type(ID),
+			desc: {
+				'ja-JP': '対象となるユーザーグループのID',
+				'en-US': 'ID of target user group'
+			}
+		},
+
+		name: {
+			validator: $.str.range(1, 100),
+			desc: {
+				'ja-JP': 'このユーザーグループの名前',
+				'en-US': 'name of this user group'
+			}
+		}
+	},
+
+	errors: {
+		noSuchGroup: {
+			message: 'No such group.',
+			code: 'NO_SUCH_GROUP',
+			id: '9081cda3-7a9e-4fac-a6ce-908d70f282f6'
+		},
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	// Fetch the group
+	const userGroup = await UserGroups.findOne({
+		id: ps.groupId,
+		userId: me.id
+	});
+
+	if (userGroup == null) {
+		throw new ApiError(meta.errors.noSuchGroup);
+	}
+
+	await UserGroups.update(userGroup.id, {
+		name: ps.name
+	});
+
+	return await UserGroups.pack(userGroup.id);
+});

From c7456224af3fbe39b3207f936781ac9b09a37a5f Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 22 May 2019 12:55:53 +0900
Subject: [PATCH 2/6] Fix bug

---
 src/client/app/common/views/pages/user-group-editor.vue | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/client/app/common/views/pages/user-group-editor.vue b/src/client/app/common/views/pages/user-group-editor.vue
index a32148cd7f..3cfecd837f 100644
--- a/src/client/app/common/views/pages/user-group-editor.vue
+++ b/src/client/app/common/views/pages/user-group-editor.vue
@@ -158,6 +158,7 @@ export default Vue.extend({
 		},
 
 		async invite() {
+			const t = this.$t('invited');
 			const { result: user } = await this.$root.dialog({
 				user: {
 					local: true
@@ -170,7 +171,7 @@ export default Vue.extend({
 			}).then(() => {
 				this.$root.dialog({
 					type: 'success',
-					text: this.$t('invited')
+					text: t
 				});
 			}).catch(e => {
 				this.$root.dialog({

From 50d4de19f0d93c78f70f7821bd9798317bd6b8d6 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 22 May 2019 12:56:42 +0900
Subject: [PATCH 3/6] Clean up

---
 src/client/app/init.ts | 2 --
 1 file changed, 2 deletions(-)

diff --git a/src/client/app/init.ts b/src/client/app/init.ts
index 52da380e84..da7baff4fe 100644
--- a/src/client/app/init.ts
+++ b/src/client/app/init.ts
@@ -78,7 +78,6 @@ import {
 	faKey,
 	faBan,
 	faCogs,
-	faCrown,
 	faUnlockAlt,
 	faPuzzlePiece,
 	faMobileAlt,
@@ -211,7 +210,6 @@ library.add(
 	faKey,
 	faBan,
 	faCogs,
-	faCrown,
 	faUnlockAlt,
 	faPuzzlePiece,
 	faMobileAlt,

From 4a88cb596815c77630be7d01d40687c49b0560d2 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 22 May 2019 12:58:44 +0900
Subject: [PATCH 4/6] rename

---
 src/models/repositories/user-group.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/models/repositories/user-group.ts b/src/models/repositories/user-group.ts
index dbbe8bf84c..38174ce235 100644
--- a/src/models/repositories/user-group.ts
+++ b/src/models/repositories/user-group.ts
@@ -21,7 +21,7 @@ export class UserGroupRepository extends Repository<UserGroup> {
 			id: userGroup.id,
 			createdAt: userGroup.createdAt.toISOString(),
 			name: userGroup.name,
-			owner: userGroup.userId,
+			ownerId: userGroup.userId,
 			userIds: users.map(x => x.userId)
 		};
 	}
@@ -49,7 +49,7 @@ export const packedUserGroupSchema = {
 			optional: bool.false, nullable: bool.false,
 			description: 'The name of the UserGroup.'
 		},
-		owner: {
+		ownerId: {
 			type: types.string,
 			nullable: bool.false, optional: bool.false,
 			format: 'id',

From eed9266ecbc26282dcc349eaacaa49d8d3a812e1 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 22 May 2019 12:58:53 +0900
Subject: [PATCH 5/6] align format

---
 src/server/api/endpoints/users/groups/transfer.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/server/api/endpoints/users/groups/transfer.ts b/src/server/api/endpoints/users/groups/transfer.ts
index 3baa182abf..ca0ab9ab29 100644
--- a/src/server/api/endpoints/users/groups/transfer.ts
+++ b/src/server/api/endpoints/users/groups/transfer.ts
@@ -74,7 +74,7 @@ export default define(meta, async (ps, me) => {
 		userId: user.id
 	});
 
-	if (!joining) {
+	if (joining == null) {
 		throw new ApiError(meta.errors.noSuchGroupMember);
 	}
 

From 9d75ec799b15758b6da69575ab8355735e036f28 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 22 May 2019 13:00:36 +0900
Subject: [PATCH 6/6] Better deninition

---
 src/server/api/endpoints/users/groups/transfer.ts | 7 +++++++
 src/server/api/endpoints/users/groups/update.ts   | 7 +++++++
 2 files changed, 14 insertions(+)

diff --git a/src/server/api/endpoints/users/groups/transfer.ts b/src/server/api/endpoints/users/groups/transfer.ts
index ca0ab9ab29..b4284ab484 100644
--- a/src/server/api/endpoints/users/groups/transfer.ts
+++ b/src/server/api/endpoints/users/groups/transfer.ts
@@ -4,6 +4,7 @@ import define from '../../../define';
 import { ApiError } from '../../../error';
 import { getUser } from '../../../common/getters';
 import { UserGroups, UserGroupJoinings } from '../../../../../models';
+import { types, bool } from '../../../../../misc/schema';
 
 export const meta = {
 	desc: {
@@ -31,6 +32,12 @@ export const meta = {
 		},
 	},
 
+	res: {
+		type: types.object,
+		optional: bool.false, nullable: bool.false,
+		ref: 'UserGroup',
+	},
+
 	errors: {
 		noSuchGroup: {
 			message: 'No such group.',
diff --git a/src/server/api/endpoints/users/groups/update.ts b/src/server/api/endpoints/users/groups/update.ts
index ad9a1faa23..bc974621a3 100644
--- a/src/server/api/endpoints/users/groups/update.ts
+++ b/src/server/api/endpoints/users/groups/update.ts
@@ -3,6 +3,7 @@ import { ID } from '../../../../../misc/cafy-id';
 import define from '../../../define';
 import { ApiError } from '../../../error';
 import { UserGroups } from '../../../../../models';
+import { types, bool } from '../../../../../misc/schema';
 
 export const meta = {
 	desc: {
@@ -34,6 +35,12 @@ export const meta = {
 		}
 	},
 
+	res: {
+		type: types.object,
+		optional: bool.false, nullable: bool.false,
+		ref: 'UserGroup',
+	},
+
 	errors: {
 		noSuchGroup: {
 			message: 'No such group.',