From e103904a0454d8a05cea60d984bc1ef1e2b9e652 Mon Sep 17 00:00:00 2001 From: syuilo 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 { + 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 { + 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 @@
- {{ $t('add-user') }} + {{ $t('invite') }}
@@ -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 @@ 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 { + 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)));