From 114c7fe6b37dd6bddbcd9d92406f8b13bf688e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Fri, 24 Nov 2023 03:26:58 +0900 Subject: [PATCH] =?UTF-8?q?feature(backend):=20=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E7=B5=B1=E8=A8=88=E8=A1=A8=E7=A4=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E5=BE=A9=E6=B4=BB=20(MisskeyIO#258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../src/server/api/endpoints/users/stats.ts | 233 ++++++++++++++++++ .../src/pages/settings/account-stats.vue | 151 ++++++++++++ .../frontend/src/pages/settings/other.vue | 2 + packages/frontend/src/router.ts | 4 + packages/misskey-js/etc/misskey-js.api.md | 4 + packages/misskey-js/src/api.types.ts | 2 +- 8 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/server/api/endpoints/users/stats.ts create mode 100644 packages/frontend/src/pages/settings/account-stats.vue diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index c96fd9de30..08612fc04d 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -362,6 +362,7 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; +import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; @@ -727,6 +728,7 @@ const $users_reportAbuse: Provider = { provide: 'ep:users/report-abuse', useClas const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-username-and-host', useClass: ep___users_searchByUsernameAndHost.default }; const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; +const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; @@ -1096,6 +1098,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_searchByUsernameAndHost, $users_search, $users_show, + $users_stats, $users_achievements, $users_updateMemo, $fetchRss, @@ -1456,6 +1459,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_searchByUsernameAndHost, $users_search, $users_show, + $users_stats, $users_achievements, $users_updateMemo, $fetchRss, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 2fa53f0e57..9fc01e7e1c 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -362,6 +362,7 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; +import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; @@ -725,6 +726,7 @@ const eps = [ ['users/search-by-username-and-host', ep___users_searchByUsernameAndHost], ['users/search', ep___users_search], ['users/show', ep___users_show], + ['users/stats', ep___users_stats], ['users/achievements', ep___users_achievements], ['users/update-memo', ep___users_updateMemo], ['fetch-rss', ep___fetchRss], diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts new file mode 100644 index 0000000000..59528a77bf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, DriveFilesRepository, NoteReactionsRepository, PageLikesRepository, NoteFavoritesRepository, PollVotesRepository } from '@/models/_.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + description: 'Show statistics about a user.', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9e638e45-3b25-4ef7-8f95-07e8498f1819', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + notesCount: { + type: 'integer', + optional: false, nullable: false, + }, + repliesCount: { + type: 'integer', + optional: false, nullable: false, + }, + renotesCount: { + type: 'integer', + optional: false, nullable: false, + }, + repliedCount: { + type: 'integer', + optional: false, nullable: false, + }, + renotedCount: { + type: 'integer', + optional: false, nullable: false, + }, + pollVotesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pollVotedCount: { + type: 'integer', + optional: false, nullable: false, + }, + localFollowingCount: { + type: 'integer', + optional: false, nullable: false, + }, + remoteFollowingCount: { + type: 'integer', + optional: false, nullable: false, + }, + localFollowersCount: { + type: 'integer', + optional: false, nullable: false, + }, + remoteFollowersCount: { + type: 'integer', + optional: false, nullable: false, + }, + followingCount: { + type: 'integer', + optional: false, nullable: false, + }, + followersCount: { + type: 'integer', + optional: false, nullable: false, + }, + sentReactionsCount: { + type: 'integer', + optional: false, nullable: false, + }, + receivedReactionsCount: { + type: 'integer', + optional: false, nullable: false, + }, + noteFavoritesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pageLikesCount: { + type: 'integer', + optional: false, nullable: false, + }, + pageLikedCount: { + type: 'integer', + optional: false, nullable: false, + }, + driveFilesCount: { + type: 'integer', + optional: false, nullable: false, + }, + driveUsage: { + type: 'integer', + optional: false, nullable: false, + description: 'Drive usage in bytes', + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, + + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, + + private driveFileEntityService: DriveFileEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const result = await awaitAll({ + notesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + repliesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.replyId IS NOT NULL') + .getCount(), + renotesCount: this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.renoteId IS NOT NULL') + .getCount(), + repliedCount: this.notesRepository.createQueryBuilder('note') + .where('note.replyUserId = :userId', { userId: user.id }) + .getCount(), + renotedCount: this.notesRepository.createQueryBuilder('note') + .where('note.renoteUserId = :userId', { userId: user.id }) + .getCount(), + pollVotesCount: this.pollVotesRepository.createQueryBuilder('vote') + .where('vote.userId = :userId', { userId: user.id }) + .getCount(), + pollVotedCount: this.pollVotesRepository.createQueryBuilder('vote') + .innerJoin('vote.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + localFollowingCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NULL') + .getCount(), + remoteFollowingCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NOT NULL') + .getCount(), + localFollowersCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NULL') + .getCount(), + remoteFollowersCount: this.followingsRepository.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NOT NULL') + .getCount(), + sentReactionsCount: this.noteReactionsRepository.createQueryBuilder('reaction') + .where('reaction.userId = :userId', { userId: user.id }) + .getCount(), + receivedReactionsCount: this.noteReactionsRepository.createQueryBuilder('reaction') + .innerJoin('reaction.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + noteFavoritesCount: this.noteFavoritesRepository.createQueryBuilder('favorite') + .where('favorite.userId = :userId', { userId: user.id }) + .getCount(), + pageLikesCount: this.pageLikesRepository.createQueryBuilder('like') + .where('like.userId = :userId', { userId: user.id }) + .getCount(), + pageLikedCount: this.pageLikesRepository.createQueryBuilder('like') + .innerJoin('like.page', 'page') + .where('page.userId = :userId', { userId: user.id }) + .getCount(), + driveFilesCount: this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .getCount(), + driveUsage: this.driveFileEntityService.calcDriveUsageOf(user), + }); + + return { + ...result, + followingCount: result.localFollowingCount + result.remoteFollowingCount, + followersCount: result.localFollowersCount + result.remoteFollowersCount, + }; + }); + } +} diff --git a/packages/frontend/src/pages/settings/account-stats.vue b/packages/frontend/src/pages/settings/account-stats.vue new file mode 100644 index 0000000000..89de92b930 --- /dev/null +++ b/packages/frontend/src/pages/settings/account-stats.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index a921e0cea9..5aa0451372 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -31,6 +31,8 @@ SPDX-License-Identifier: AGPL-3.0-only + + {{ i18n.ts.statistics }} diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index b81811d2e7..c55bcd1441 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -170,6 +170,10 @@ export const routes = [{ path: '/accounts', name: 'profile', component: page(() => import('./pages/settings/accounts.vue')), + }, { + path: '/account-stats', + name: 'other', + component: page(() => import('./pages/settings/account-stats.vue')), }, { path: '/other', name: 'other', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index b7549a896c..303d2d397d 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2273,6 +2273,10 @@ export type Endpoints = { }; }; }; + 'users/stats': { + req: TODO; + res: TODO; + }; 'fetch-rss': { req: { url: string; diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 4d5fbd19cb..5e1cc9ee02 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -644,7 +644,7 @@ export type Endpoints = { $default: UserDetailed; }; }; }; - + 'users/stats': { req: TODO; res: TODO; }; // fetching external data 'fetch-rss': { req: { url: string; }; res: TODO; }; 'fetch-external-resources': {