From e103904a0454d8a05cea60d984bc1ef1e2b9e652 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 19 May 2019 20:41:23 +0900
Subject: [PATCH] Resolve #4941

---
 locales/ja-JP.yml                             |  9 +-
 migration/1558257926829-UserGroupInvite.ts    | 25 +++++
 .../app/common/views/components/messaging.vue |  5 +-
 .../common/views/pages/user-group-editor.vue  | 12 ++-
 .../app/common/views/pages/user-groups.vue    | 92 +++++++++++++++----
 src/db/postgre.ts                             |  2 +
 src/models/entities/user-group-invite.ts      | 42 +++++++++
 src/models/entities/user-group-joining.ts     |  1 +
 src/models/index.ts                           |  2 +
 src/models/repositories/user-group-invite.ts  | 24 +++++
 .../api/endpoints/i/user-group-invites.ts     | 45 +++++++++
 .../users/groups/invitations/accept.ts        | 63 +++++++++++++
 .../users/groups/invitations/reject.ts        | 53 +++++++++++
 .../users/groups/{push.ts => invite.ts}       | 32 +++++--
 .../api/endpoints/users/groups/joined.ts      |  6 ++
 15 files changed, 379 insertions(+), 34 deletions(-)
 create mode 100644 migration/1558257926829-UserGroupInvite.ts
 create mode 100644 src/models/entities/user-group-invite.ts
 create mode 100644 src/models/repositories/user-group-invite.ts
 create mode 100644 src/server/api/endpoints/i/user-group-invites.ts
 create mode 100644 src/server/api/endpoints/users/groups/invitations/accept.ts
 create mode 100644 src/server/api/endpoints/users/groups/invitations/reject.ts
 rename src/server/api/endpoints/users/groups/{push.ts => invite.ts} (70%)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 529a63e6ea..9047378849 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -485,6 +485,7 @@ common/views/components/messaging.vue:
   group: "グループ"
   start-with-user: "ユーザーとトークを開始"
   start-with-group: "グループとトークを開始"
+  select-group: "グループを選択してください"
 
 common/views/components/messaging-room.vue:
   not-talked-user: "このユーザーとの会話はありません"
@@ -764,7 +765,8 @@ common/views/components/user-group-editor.vue:
   remove-user: "このグループから削除"
   delete-are-you-sure: "グループ「$1」を削除しますか?"
   deleted: "削除しました"
-  add-user: "メンバーを追加"
+  invite: "招待"
+  invited: "招待を送信しました"
 
 common/views/components/user-lists.vue:
   user-lists: "リスト"
@@ -775,6 +777,11 @@ common/views/components/user-groups.vue:
   user-groups: "グループ"
   create-group: "グループを作成"
   group-name: "グループ名"
+  owned-groups: "自分のグループ"
+  joined-groups: "参加しているグループ"
+  invites: "招待"
+  accept-invite: "参加"
+  reject-invite: "拒否"
 
 common/views/widgets/broadcast.vue:
   fetching: "確認中"
diff --git a/migration/1558257926829-UserGroupInvite.ts b/migration/1558257926829-UserGroupInvite.ts
new file mode 100644
index 0000000000..a78e479228
--- /dev/null
+++ b/migration/1558257926829-UserGroupInvite.ts
@@ -0,0 +1,25 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class UserGroupInvite1558257926829 implements MigrationInterface {
+
+    public async up(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`CREATE TABLE "user_group_invite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userGroupId" character varying(32) NOT NULL, CONSTRAINT "PK_3893884af0d3a5f4d01e7921a97" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_1039988afa3bf991185b277fe0" ON "user_group_invite" ("userId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_e10924607d058004304611a436" ON "user_group_invite" ("userGroupId") `);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_78787741f9010886796f2320a4" ON "user_group_invite" ("userId", "userGroupId") `);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d9ecaed8c6dc43f3592c229282" ON "user_group_joining" ("userId", "userGroupId") `);
+        await queryRunner.query(`ALTER TABLE "user_group_invite" ADD CONSTRAINT "FK_1039988afa3bf991185b277fe03" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "user_group_invite" ADD CONSTRAINT "FK_e10924607d058004304611a436a" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`ALTER TABLE "user_group_invite" DROP CONSTRAINT "FK_e10924607d058004304611a436a"`);
+        await queryRunner.query(`ALTER TABLE "user_group_invite" DROP CONSTRAINT "FK_1039988afa3bf991185b277fe03"`);
+        await queryRunner.query(`DROP INDEX "IDX_d9ecaed8c6dc43f3592c229282"`);
+        await queryRunner.query(`DROP INDEX "IDX_78787741f9010886796f2320a4"`);
+        await queryRunner.query(`DROP INDEX "IDX_e10924607d058004304611a436"`);
+        await queryRunner.query(`DROP INDEX "IDX_1039988afa3bf991185b277fe0"`);
+        await queryRunner.query(`DROP TABLE "user_group_invite"`);
+    }
+
+}
diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue
index 01d7a5a798..c7c70a4ce5 100644
--- a/src/client/app/common/views/components/messaging.vue
+++ b/src/client/app/common/views/components/messaging.vue
@@ -217,12 +217,13 @@ export default Vue.extend({
 			this.navigate(user);
 		},
 		async startGroup() {
-			const groups = await this.$root.api('users/groups/joined');
+			const groups1 = await this.$root.api('users/groups/owned');
+			const groups2 = await this.$root.api('users/groups/joined');
 			const { canceled, result: group } = await this.$root.dialog({
 				type: null,
 				title: this.$t('select-group'),
 				select: {
-					items: groups.map(group => ({
+					items: groups1.concat(groups2).map(group => ({
 						value: group, text: group.name
 					}))
 				},
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 fb9f1a6772..ef79689ae8 100644
--- a/src/client/app/common/views/pages/user-group-editor.vue
+++ b/src/client/app/common/views/pages/user-group-editor.vue
@@ -16,7 +16,7 @@
 
 		<section>
 			<ui-margin>
-				<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add-user') }}</ui-button>
+				<ui-button @click="invite()"><fa :icon="faPlus"/> {{ $t('invite') }}</ui-button>
 			</ui-margin>
 			<sequential-entrance animation="entranceFromTop" delay="25">
 				<div class="kjlrfbes" v-for="user in users">
@@ -134,18 +134,22 @@ export default Vue.extend({
 			});
 		},
 
-		async add() {
+		async invite() {
+			const t = this.$t('invited');
 			const { result: user } = await this.$root.dialog({
 				user: {
 					local: true
 				}
 			});
 			if (user == null) return;
-			this.$root.api('users/groups/push', {
+			this.$root.api('users/groups/invite', {
 				groupId: this.group.id,
 				userId: user.id
 			}).then(() => {
-				this.fetchUsers();
+				this.$root.dialog({
+					type: 'success',
+					text: t
+				});
 			});
 		}
 	}
diff --git a/src/client/app/common/views/pages/user-groups.vue b/src/client/app/common/views/pages/user-groups.vue
index 336772799b..2a89196e55 100644
--- a/src/client/app/common/views/pages/user-groups.vue
+++ b/src/client/app/common/views/pages/user-groups.vue
@@ -1,36 +1,70 @@
 <template>
-<ui-container>
-	<template #header><fa :icon="faUsers"/> {{ $t('user-groups') }}</template>
-	<ui-margin>
-		<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('create-group') }}</ui-button>
-	</ui-margin>
-	<div class="hwgkdrbl" v-for="group in groups" :key="group.id">
-		<ui-hr/>
+<div>
+	<ui-container>
+		<template #header><fa :icon="faUsers"/> {{ $t('owned-groups') }}</template>
 		<ui-margin>
-			<router-link :to="`/i/groups/${group.id}`">{{ group.name }}</router-link>
+			<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('create-group') }}</ui-button>
 		</ui-margin>
-	</div>
-</ui-container>
+		<div class="hwgkdrbl" v-for="group in ownedGroups" :key="group.id">
+			<ui-hr/>
+			<ui-margin>
+				<router-link :to="`/i/groups/${group.id}`">{{ group.name }}</router-link>
+			</ui-margin>
+		</div>
+	</ui-container>
+
+	<ui-container>
+		<template #header><fa :icon="faUsers"/> {{ $t('joined-groups') }}</template>
+		<div class="hwgkdrbl" v-for="(group, i) in joinedGroups" :key="group.id">
+			<ui-hr v-if="i != 0"/>
+			<ui-margin>
+				<router-link :to="`/i/groups/${group.id}`">{{ group.name }}</router-link>
+			</ui-margin>
+		</div>
+	</ui-container>
+
+	<ui-container>
+		<template #header><fa :icon="faEnvelopeOpenText"/> {{ $t('invites') }}</template>
+		<div class="fvlojuur" v-for="(invite, i) in invites" :key="invite.id">
+			<ui-hr v-if="i != 0"/>
+			<ui-margin>
+				<div class="name">{{ invite.group.name }}</div>
+				<ui-horizon-group>
+					<ui-button @click="acceptInvite(invite)"><fa :icon="faCheck"/> {{ $t('accept-invite') }}</ui-button>
+					<ui-button @click="rejectInvite(invite)"><fa :icon="faBan"/> {{ $t('reject-invite') }}</ui-button>
+				</ui-horizon-group>
+			</ui-margin>
+		</div>
+	</ui-container>
+</div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
-import { faUsers, faPlus } from '@fortawesome/free-solid-svg-icons';
+import { faUsers, faPlus, faCheck, faBan, faEnvelopeOpenText } from '@fortawesome/free-solid-svg-icons';
 
 export default Vue.extend({
 	i18n: i18n('common/views/components/user-groups.vue'),
 	data() {
 		return {
-			fetching: true,
-			groups: [],
-			faUsers, faPlus
+			ownedGroups: [],
+			joinedGroups: [],
+			invites: [],
+			faUsers, faPlus, faCheck, faBan, faEnvelopeOpenText
 		};
 	},
 	mounted() {
 		this.$root.api('users/groups/owned').then(groups => {
-			this.fetching = false;
-			this.groups = groups;
+			this.ownedGroups = groups;
+		});
+
+		this.$root.api('users/groups/joined').then(groups => {
+			this.joinedGroups = groups;
+		});
+
+		this.$root.api('i/user-group-invites').then(invites => {
+			this.invites = invites;
 		});
 
 		this.$emit('init', {
@@ -45,13 +79,35 @@ export default Vue.extend({
 				input: true
 			}).then(async ({ canceled, result: name }) => {
 				if (canceled) return;
-				const list = await this.$root.api('users/groups/create', {
+				const group = await this.$root.api('users/groups/create', {
 					name
 				});
 
-				this.groups.push(list)
+				this.ownedGroups.push(group)
 			});
 		},
+		acceptInvite(invite) {
+			this.$root.api('users/groups/invitations/accept', {
+				inviteId: invite.id
+			}).then(() => {
+				this.$root.dialog({
+					type: 'success',
+					splash: true
+				});
+				this.$root.api('i/user-group-invites').then(invites => {
+					this.invites = invites;
+				});
+			});
+		},
+		rejectInvite(invite) {
+			this.$root.api('users/groups/invitations/reject', {
+				inviteId: invite.id
+			}).then(() => {
+				this.$root.api('i/user-group-invites').then(invites => {
+					this.invites = invites;
+				});
+			});
+		}
 	}
 });
 </script>
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index 40b9ce151b..fcec68602f 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -26,6 +26,7 @@ import { UserList } from '../models/entities/user-list';
 import { UserListJoining } from '../models/entities/user-list-joining';
 import { UserGroup } from '../models/entities/user-group';
 import { UserGroupJoining } from '../models/entities/user-group-joining';
+import { UserGroupInvite } from '../models/entities/user-group-invite';
 import { Hashtag } from '../models/entities/hashtag';
 import { NoteFavorite } from '../models/entities/note-favorite';
 import { AbuseUserReport } from '../models/entities/abuse-user-report';
@@ -110,6 +111,7 @@ export function initDb(justBorrow = false, sync = false, log = false) {
 			UserListJoining,
 			UserGroup,
 			UserGroupJoining,
+			UserGroupInvite,
 			UserNotePining,
 			Following,
 			FollowRequest,
diff --git a/src/models/entities/user-group-invite.ts b/src/models/entities/user-group-invite.ts
new file mode 100644
index 0000000000..2adf2c024e
--- /dev/null
+++ b/src/models/entities/user-group-invite.ts
@@ -0,0 +1,42 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { User } from './user';
+import { UserGroup } from './user-group';
+import { id } from '../id';
+
+@Entity()
+@Index(['userId', 'userGroupId'], { unique: true })
+export class UserGroupInvite {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the UserGroupInvite.'
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The user ID.'
+	})
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public user: User | null;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The group ID.'
+	})
+	public userGroupId: UserGroup['id'];
+
+	@ManyToOne(type => UserGroup, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public userGroup: UserGroup | null;
+}
diff --git a/src/models/entities/user-group-joining.ts b/src/models/entities/user-group-joining.ts
index 17b534f42f..e09c3230f1 100644
--- a/src/models/entities/user-group-joining.ts
+++ b/src/models/entities/user-group-joining.ts
@@ -4,6 +4,7 @@ import { UserGroup } from './user-group';
 import { id } from '../id';
 
 @Entity()
+@Index(['userId', 'userGroupId'], { unique: true })
 export class UserGroupJoining {
 	@PrimaryColumn(id())
 	public id: string;
diff --git a/src/models/index.ts b/src/models/index.ts
index c05d7febe5..a60cd10ef9 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -22,6 +22,7 @@ import { UserListRepository } from './repositories/user-list';
 import { UserListJoining } from './entities/user-list-joining';
 import { UserGroupRepository } from './repositories/user-group';
 import { UserGroupJoining } from './entities/user-group-joining';
+import { UserGroupInviteRepository } from './repositories/user-group-invite';
 import { FollowRequestRepository } from './repositories/follow-request';
 import { MutingRepository } from './repositories/muting';
 import { BlockingRepository } from './repositories/blocking';
@@ -56,6 +57,7 @@ export const UserLists = getCustomRepository(UserListRepository);
 export const UserListJoinings = getRepository(UserListJoining);
 export const UserGroups = getCustomRepository(UserGroupRepository);
 export const UserGroupJoinings = getRepository(UserGroupJoining);
+export const UserGroupInvites = getCustomRepository(UserGroupInviteRepository);
 export const UserNotePinings = getRepository(UserNotePining);
 export const Followings = getCustomRepository(FollowingRepository);
 export const FollowRequests = getCustomRepository(FollowRequestRepository);
diff --git a/src/models/repositories/user-group-invite.ts b/src/models/repositories/user-group-invite.ts
new file mode 100644
index 0000000000..1d4c2aa15f
--- /dev/null
+++ b/src/models/repositories/user-group-invite.ts
@@ -0,0 +1,24 @@
+import { EntityRepository, Repository } from 'typeorm';
+import { UserGroupInvite } from '../entities/user-group-invite';
+import { UserGroups } from '..';
+import { ensure } from '../../prelude/ensure';
+
+@EntityRepository(UserGroupInvite)
+export class UserGroupInviteRepository extends Repository<UserGroupInvite> {
+	public async pack(
+		src: UserGroupInvite['id'] | UserGroupInvite,
+	) {
+		const invite = typeof src === 'object' ? src : await this.findOne(src).then(ensure);
+
+		return {
+			id: invite.id,
+			group: await UserGroups.pack(invite.userGroup || invite.userGroupId),
+		};
+	}
+
+	public packMany(
+		invites: any[],
+	) {
+		return Promise.all(invites.map(x => this.pack(x)));
+	}
+}
diff --git a/src/server/api/endpoints/i/user-group-invites.ts b/src/server/api/endpoints/i/user-group-invites.ts
new file mode 100644
index 0000000000..9d07fa31a4
--- /dev/null
+++ b/src/server/api/endpoints/i/user-group-invites.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import { ID } from '../../../../misc/cafy-id';
+import define from '../../define';
+import { UserGroupInvites } from '../../../../models';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'グループへの招待一覧を取得します。',
+		'en-US': 'Get user group invitations.'
+	},
+
+	tags: ['account', 'groups'],
+
+	requireCredential: true,
+
+	kind: 'read:user-groups',
+
+	params: {
+		limit: {
+			validator: $.optional.num.range(1, 100),
+			default: 10
+		},
+
+		sinceId: {
+			validator: $.optional.type(ID),
+		},
+
+		untilId: {
+			validator: $.optional.type(ID),
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const query = makePaginationQuery(UserGroupInvites.createQueryBuilder('invite'), ps.sinceId, ps.untilId)
+		.andWhere(`invite.userId = :meId`, { meId: user.id })
+		.leftJoinAndSelect('invite.userGroup', 'user_group');
+
+	const invites = await query
+		.take(ps.limit!)
+		.getMany();
+
+	return await UserGroupInvites.packMany(invites);
+});
diff --git a/src/server/api/endpoints/users/groups/invitations/accept.ts b/src/server/api/endpoints/users/groups/invitations/accept.ts
new file mode 100644
index 0000000000..33779dd349
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/invitations/accept.ts
@@ -0,0 +1,63 @@
+import $ from 'cafy';
+import { ID } from '../../../../../../misc/cafy-id';
+import define from '../../../../define';
+import { ApiError } from '../../../../error';
+import { UserGroupJoinings, UserGroupInvites } from '../../../../../../models';
+import { genId } from '../../../../../../misc/gen-id';
+import { UserGroupJoining } from '../../../../../../models/entities/user-group-joining';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'ユーザーグループへの招待を承認します。',
+		'en-US': 'Accept invite of a user group.'
+	},
+
+	tags: ['groups', 'users'],
+
+	requireCredential: true,
+
+	kind: 'write:user-groups',
+
+	params: {
+		inviteId: {
+			validator: $.type(ID),
+			desc: {
+				'ja-JP': '招待ID',
+				'en-US': 'The invite ID'
+			}
+		},
+	},
+
+	errors: {
+		noSuchInvitation: {
+			message: 'No such invitation.',
+			code: 'NO_SUCH_INVITATION',
+			id: '98c11eca-c890-4f42-9806-c8c8303ebb5e'
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	// Fetch the invitation
+	const invite = await UserGroupInvites.findOne({
+		id: ps.inviteId,
+	});
+
+	if (invite == null) {
+		throw new ApiError(meta.errors.noSuchInvitation);
+	}
+
+	if (invite.userId !== user.id) {
+		throw new ApiError(meta.errors.noSuchInvitation);
+	}
+
+	// Push the user
+	await UserGroupJoinings.save({
+		id: genId(),
+		createdAt: new Date(),
+		userId: user.id,
+		userGroupId: invite.userGroupId
+	} as UserGroupJoining);
+
+	UserGroupInvites.delete(invite.id);
+});
diff --git a/src/server/api/endpoints/users/groups/invitations/reject.ts b/src/server/api/endpoints/users/groups/invitations/reject.ts
new file mode 100644
index 0000000000..e9e7bc8b48
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/invitations/reject.ts
@@ -0,0 +1,53 @@
+import $ from 'cafy';
+import { ID } from '../../../../../../misc/cafy-id';
+import define from '../../../../define';
+import { ApiError } from '../../../../error';
+import { UserGroupInvites } from '../../../../../../models';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'ユーザーグループへの招待を拒否します。',
+		'en-US': 'Reject invite of a user group.'
+	},
+
+	tags: ['groups', 'users'],
+
+	requireCredential: true,
+
+	kind: 'write:user-groups',
+
+	params: {
+		inviteId: {
+			validator: $.type(ID),
+			desc: {
+				'ja-JP': '招待ID',
+				'en-US': 'The invite ID'
+			}
+		},
+	},
+
+	errors: {
+		noSuchInvitation: {
+			message: 'No such invitation.',
+			code: 'NO_SUCH_INVITATION',
+			id: 'ad7471d4-2cd9-44b4-ac68-e7136b4ce656'
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	// Fetch the invitation
+	const invite = await UserGroupInvites.findOne({
+		id: ps.inviteId,
+	});
+
+	if (invite == null) {
+		throw new ApiError(meta.errors.noSuchInvitation);
+	}
+
+	if (invite.userId !== user.id) {
+		throw new ApiError(meta.errors.noSuchInvitation);
+	}
+
+	await UserGroupInvites.delete(invite.id);
+});
diff --git a/src/server/api/endpoints/users/groups/push.ts b/src/server/api/endpoints/users/groups/invite.ts
similarity index 70%
rename from src/server/api/endpoints/users/groups/push.ts
rename to src/server/api/endpoints/users/groups/invite.ts
index 5371580db0..503184a92f 100644
--- a/src/server/api/endpoints/users/groups/push.ts
+++ b/src/server/api/endpoints/users/groups/invite.ts
@@ -3,14 +3,14 @@ import { ID } from '../../../../../misc/cafy-id';
 import define from '../../../define';
 import { ApiError } from '../../../error';
 import { getUser } from '../../../common/getters';
-import { UserGroups, UserGroupJoinings } from '../../../../../models';
+import { UserGroups, UserGroupJoinings, UserGroupInvites } from '../../../../../models';
 import { genId } from '../../../../../misc/gen-id';
-import { UserGroupJoining } from '../../../../../models/entities/user-group-joining';
+import { UserGroupInvite } from '../../../../../models/entities/user-group-invite';
 
 export const meta = {
 	desc: {
-		'ja-JP': '指定したユーザーグループに指定したユーザーを追加します。',
-		'en-US': 'Add a user to a user group.'
+		'ja-JP': '指定したユーザーグループに指定したユーザーを招待します。',
+		'en-US': 'Invite a user to a user group.'
 	},
 
 	tags: ['groups', 'users'],
@@ -50,6 +50,12 @@ export const meta = {
 			message: 'That user has already been added to that group.',
 			code: 'ALREADY_ADDED',
 			id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c'
+		},
+
+		alreadyInvited: {
+			message: 'That user has already been invited to that group.',
+			code: 'ALREADY_INVITED',
+			id: 'ee0f58b4-b529-4d13-b761-b9a3e69f97e6'
 		}
 	}
 };
@@ -71,20 +77,28 @@ export default define(meta, async (ps, me) => {
 		throw e;
 	});
 
-	const exist = await UserGroupJoinings.findOne({
+	const joining = await UserGroupJoinings.findOne({
 		userGroupId: userGroup.id,
 		userId: user.id
 	});
 
-	if (exist) {
+	if (joining) {
 		throw new ApiError(meta.errors.alreadyAdded);
 	}
 
-	// Push the user
-	await UserGroupJoinings.save({
+	const invite = await UserGroupInvites.findOne({
+		userGroupId: userGroup.id,
+		userId: user.id
+	});
+
+	if (invite) {
+		throw new ApiError(meta.errors.alreadyInvited);
+	}
+
+	await UserGroupInvites.save({
 		id: genId(),
 		createdAt: new Date(),
 		userId: user.id,
 		userGroupId: userGroup.id
-	} as UserGroupJoining);
+	} as UserGroupInvite);
 });
diff --git a/src/server/api/endpoints/users/groups/joined.ts b/src/server/api/endpoints/users/groups/joined.ts
index 14561fce05..853a2510de 100644
--- a/src/server/api/endpoints/users/groups/joined.ts
+++ b/src/server/api/endpoints/users/groups/joined.ts
@@ -1,6 +1,7 @@
 import define from '../../../define';
 import { UserGroups, UserGroupJoinings } from '../../../../../models';
 import { types, bool } from '../../../../../misc/schema';
+import { Not, In } from 'typeorm';
 
 export const meta = {
 	desc: {
@@ -25,8 +26,13 @@ export const meta = {
 };
 
 export default define(meta, async (ps, me) => {
+	const ownedGroups = await UserGroups.find({
+		userId: me.id,
+	});
+
 	const joinings = await UserGroupJoinings.find({
 		userId: me.id,
+		userGroupId: Not(In(ownedGroups.map(x => x.id)))
 	});
 
 	return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId)));