feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知
This commit is contained in:
parent
a2cd6a7709
commit
3ab953bdf8
|
@ -9326,6 +9326,18 @@ export interface Locale extends ILocale {
|
||||||
* ログインがありました
|
* ログインがありました
|
||||||
*/
|
*/
|
||||||
"login": string;
|
"login": string;
|
||||||
|
/**
|
||||||
|
* システムからの通知
|
||||||
|
*/
|
||||||
|
"fromSystem": string;
|
||||||
|
/**
|
||||||
|
* モデレーターが一定期間非アクティブになっています。{timeVariant}まで非アクティブな状態が続くと招待制に切り替わります。
|
||||||
|
*/
|
||||||
|
"adminInactiveModeratorsWarning": ParameterizedString<"timeVariant">;
|
||||||
|
/**
|
||||||
|
* モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されました。
|
||||||
|
*/
|
||||||
|
"adminInactiveModeratorsInvitationOnlyChanged": string;
|
||||||
"_types": {
|
"_types": {
|
||||||
/**
|
/**
|
||||||
* すべて
|
* すべて
|
||||||
|
@ -9633,6 +9645,14 @@ export interface Locale extends ILocale {
|
||||||
* ユーザーが作成されたとき
|
* ユーザーが作成されたとき
|
||||||
*/
|
*/
|
||||||
"userCreated": string;
|
"userCreated": string;
|
||||||
|
/**
|
||||||
|
* モデレーターが一定期間非アクティブになったとき
|
||||||
|
*/
|
||||||
|
"inactiveModeratorsWarning": string;
|
||||||
|
/**
|
||||||
|
* モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき
|
||||||
|
*/
|
||||||
|
"inactiveModeratorsInvitationOnlyChanged": string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Webhookを削除しますか?
|
* Webhookを削除しますか?
|
||||||
|
|
|
@ -2462,6 +2462,9 @@ _notification:
|
||||||
flushNotification: "通知の履歴をリセットする"
|
flushNotification: "通知の履歴をリセットする"
|
||||||
exportOfXCompleted: "{x}のエクスポートが完了しました"
|
exportOfXCompleted: "{x}のエクスポートが完了しました"
|
||||||
login: "ログインがありました"
|
login: "ログインがありました"
|
||||||
|
fromSystem: "システムからの通知"
|
||||||
|
adminInactiveModeratorsWarning: "モデレーターが一定期間非アクティブになっています。{timeVariant}まで非アクティブな状態が続くと招待制に切り替わります。"
|
||||||
|
adminInactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されました。"
|
||||||
|
|
||||||
_types:
|
_types:
|
||||||
all: "すべて"
|
all: "すべて"
|
||||||
|
@ -2552,6 +2555,8 @@ _webhookSettings:
|
||||||
abuseReport: "ユーザーから通報があったとき"
|
abuseReport: "ユーザーから通報があったとき"
|
||||||
abuseReportResolved: "ユーザーからの通報を処理したとき"
|
abuseReportResolved: "ユーザーからの通報を処理したとき"
|
||||||
userCreated: "ユーザーが作成されたとき"
|
userCreated: "ユーザーが作成されたとき"
|
||||||
|
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
|
||||||
|
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
|
||||||
deleteConfirm: "Webhookを削除しますか?"
|
deleteConfirm: "Webhookを削除しますか?"
|
||||||
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"
|
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { Packed } from '@/misc/json-schema.js';
|
||||||
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
|
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||||
|
|
||||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
@ -446,6 +447,22 @@ export class WebhookTestService {
|
||||||
send(toPackedUserLite(dummyUser1));
|
send(toPackedUserLite(dummyUser1));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'inactiveModeratorsWarning': {
|
||||||
|
const dummyTime: ModeratorInactivityRemainingTime = {
|
||||||
|
time: 100000,
|
||||||
|
asDays: 1,
|
||||||
|
asHours: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
send({
|
||||||
|
remainingTime: dummyTime,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'inactiveModeratorsInvitationOnlyChanged': {
|
||||||
|
send({});
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
async #packInternal <T extends MiNotification | MiGroupedNotification> (
|
async #packInternal <T extends MiNotification | MiGroupedNotification> (
|
||||||
src: T,
|
src: T,
|
||||||
meId: MiUser['id'],
|
meId: MiUser['id'],
|
||||||
|
|
||||||
options: {
|
options: {
|
||||||
checkValidNotifier?: boolean;
|
checkValidNotifier?: boolean;
|
||||||
},
|
},
|
||||||
|
@ -174,6 +174,9 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
header: notification.customHeader,
|
header: notification.customHeader,
|
||||||
icon: notification.customIcon,
|
icon: notification.customIcon,
|
||||||
} : {}),
|
} : {}),
|
||||||
|
...(notification.type === 'adminInactiveModeratorsWarning' ? {
|
||||||
|
remainingTime: notification.remainingTime,
|
||||||
|
} : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -236,7 +239,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||||
public async pack(
|
public async pack(
|
||||||
src: MiNotification | MiGroupedNotification,
|
src: MiNotification | MiGroupedNotification,
|
||||||
meId: MiUser['id'],
|
meId: MiUser['id'],
|
||||||
|
|
||||||
options: {
|
options: {
|
||||||
checkValidNotifier?: boolean;
|
checkValidNotifier?: boolean;
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { userExportableEntities } from '@/types.js';
|
import { userExportableEntities } from '@/types.js';
|
||||||
|
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||||
import { MiUser } from './User.js';
|
import { MiUser } from './User.js';
|
||||||
import { MiNote } from './Note.js';
|
import { MiNote } from './Note.js';
|
||||||
import { MiAccessToken } from './AccessToken.js';
|
import { MiAccessToken } from './AccessToken.js';
|
||||||
|
@ -116,6 +117,15 @@ export type MiNotification = {
|
||||||
* アプリ通知のアプリ(のトークン)
|
* アプリ通知のアプリ(のトークン)
|
||||||
*/
|
*/
|
||||||
appAccessTokenId: MiAccessToken['id'] | null;
|
appAccessTokenId: MiAccessToken['id'] | null;
|
||||||
|
} | {
|
||||||
|
type: 'adminInactiveModeratorsWarning';
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
remainingTime: ModeratorInactivityRemainingTime;
|
||||||
|
} | {
|
||||||
|
type: 'adminInactiveModeratorsInvitationOnlyChanged';
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
} | {
|
} | {
|
||||||
type: 'test';
|
type: 'test';
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
|
||||||
'abuseReportResolved',
|
'abuseReportResolved',
|
||||||
// ユーザが作成された時
|
// ユーザが作成された時
|
||||||
'userCreated',
|
'userCreated',
|
||||||
|
// モデレータが一定期間不在である警告
|
||||||
|
'inactiveModeratorsWarning',
|
||||||
|
// モデレータが一定期間不在のためシステムにより招待制へと変更された
|
||||||
|
'inactiveModeratorsInvitationOnlyChanged',
|
||||||
] as const;
|
] as const;
|
||||||
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
|
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
|
||||||
|
|
||||||
|
|
|
@ -412,6 +412,43 @@ export const packedNotificationSchema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
...baseSchema.properties,
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
enum: ['adminInactiveModeratorsWarning'],
|
||||||
|
},
|
||||||
|
remainingTime: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
time: {
|
||||||
|
type: 'number',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
asDays: {
|
||||||
|
type: 'number',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
asHours: {
|
||||||
|
type: 'number',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
...baseSchema.properties,
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
enum: ['adminInactiveModeratorsInvitationOnlyChanged'],
|
||||||
|
},
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|
|
@ -3,24 +3,91 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
|
import { EmailService } from '@/core/EmailService.js';
|
||||||
|
import { MiUser, type UserProfilesRepository } from '@/models/_.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||||
|
|
||||||
// モデレーターが不在と判断する日付の閾値
|
// モデレーターが不在と判断する日付の閾値
|
||||||
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
|
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
|
||||||
const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
|
// 警告通知やログ出力を行う残日数の閾値
|
||||||
|
const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2;
|
||||||
|
const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60;
|
||||||
|
const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24;
|
||||||
|
|
||||||
|
export type ModeratorInactivityEvaluationResult = {
|
||||||
|
isModeratorsInactive: boolean;
|
||||||
|
inactiveModerators: MiUser[];
|
||||||
|
remainingTime: ModeratorInactivityRemainingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModeratorInactivityRemainingTime = {
|
||||||
|
time: number;
|
||||||
|
asHours: number;
|
||||||
|
asDays: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) {
|
||||||
|
const subject = 'Moderator Inactivity Warning';
|
||||||
|
|
||||||
|
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
|
||||||
|
const message = [
|
||||||
|
'To Moderators,',
|
||||||
|
'',
|
||||||
|
`A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`,
|
||||||
|
'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.',
|
||||||
|
];
|
||||||
|
|
||||||
|
const html = message.join('<br>');
|
||||||
|
const text = message.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInvitationOnlyChangedMail() {
|
||||||
|
const subject = 'Change to Invitation-Only';
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
'To Moderators,',
|
||||||
|
'',
|
||||||
|
`Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`,
|
||||||
|
'To cancel the invitation only, you need to access the control panel.',
|
||||||
|
];
|
||||||
|
|
||||||
|
const html = message.join('<br>');
|
||||||
|
const text = message.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CheckModeratorsActivityProcessorService {
|
export class CheckModeratorsActivityProcessorService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DI.userProfilesRepository)
|
||||||
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
|
private notificationService: NotificationService,
|
||||||
|
private emailService: EmailService,
|
||||||
|
private systemWebhookService: SystemWebhookService,
|
||||||
private queueLoggerService: QueueLoggerService,
|
private queueLoggerService: QueueLoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
|
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
|
||||||
|
@ -42,18 +109,19 @@ export class CheckModeratorsActivityProcessorService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async processImpl() {
|
private async processImpl() {
|
||||||
const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
|
const evaluateResult = await this.evaluateModeratorsInactiveDays();
|
||||||
if (isModeratorsInactive) {
|
if (evaluateResult.isModeratorsInactive) {
|
||||||
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
|
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
|
||||||
|
|
||||||
await this.changeToInvitationOnly();
|
await this.changeToInvitationOnly();
|
||||||
|
await this.notifyChangeToInvitationOnly();
|
||||||
// TODO: モデレータに通知メール+Misskey通知
|
|
||||||
// TODO: SystemWebhook通知
|
|
||||||
} else {
|
} else {
|
||||||
if (inactivityLimitCountdown <= 2) {
|
const remainingTime = evaluateResult.remainingTime;
|
||||||
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${inactivityLimitCountdown} days, it will switch to invitation only.`);
|
if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) {
|
||||||
|
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
|
||||||
|
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`);
|
||||||
|
|
||||||
// TODO: 警告メール
|
await this.notifyInactiveModeratorsWarning(remainingTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,7 +155,7 @@ export class CheckModeratorsActivityProcessorService {
|
||||||
* この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
|
* この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async evaluateModeratorsInactiveDays() {
|
public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const inactivePeriod = new Date(today);
|
const inactivePeriod = new Date(today);
|
||||||
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
|
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
|
||||||
|
@ -101,12 +169,18 @@ export class CheckModeratorsActivityProcessorService {
|
||||||
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
|
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
|
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
|
||||||
const inactivityLimitCountdown = Math.floor((newestLastActiveDate.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC);
|
const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime();
|
||||||
|
const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC);
|
||||||
|
const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isModeratorsInactive: inactiveModerators.length === moderators.length,
|
isModeratorsInactive: inactiveModerators.length === moderators.length,
|
||||||
inactiveModerators,
|
inactiveModerators,
|
||||||
inactivityLimitCountdown,
|
remainingTime: {
|
||||||
|
time: remainingTime,
|
||||||
|
asHours: remainingTimeAsHours,
|
||||||
|
asDays: remainingTimeAsDays,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,6 +189,78 @@ export class CheckModeratorsActivityProcessorService {
|
||||||
await this.metaService.update({ disableRegistration: true });
|
await this.metaService.update({ disableRegistration: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) {
|
||||||
|
// -- モデレータへのメールと通知
|
||||||
|
|
||||||
|
const moderators = await this.fetchModerators();
|
||||||
|
const moderatorProfiles = await this.userProfilesRepository
|
||||||
|
.findBy({ userId: In(moderators.map(it => it.id)) })
|
||||||
|
.then(it => new Map(it.map(it => [it.userId, it])));
|
||||||
|
|
||||||
|
const mail = generateModeratorInactivityMail(remainingTime);
|
||||||
|
for (const moderator of moderators) {
|
||||||
|
this.notificationService.createNotification(
|
||||||
|
moderator.id,
|
||||||
|
'adminInactiveModeratorsWarning',
|
||||||
|
{ remainingTime: remainingTime },
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = moderatorProfiles.get(moderator.id);
|
||||||
|
if (profile && profile.email && profile.emailVerified) {
|
||||||
|
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text).then();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- SystemWebhook
|
||||||
|
|
||||||
|
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
|
||||||
|
.then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning')));
|
||||||
|
for (const systemWebhook of systemWebhooks) {
|
||||||
|
this.systemWebhookService.enqueueSystemWebhook(
|
||||||
|
systemWebhook,
|
||||||
|
'inactiveModeratorsWarning',
|
||||||
|
{ remainingTime: remainingTime },
|
||||||
|
).then();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async notifyChangeToInvitationOnly() {
|
||||||
|
// -- モデレータへのメールと通知
|
||||||
|
|
||||||
|
const moderators = await this.fetchModerators();
|
||||||
|
const moderatorProfiles = await this.userProfilesRepository
|
||||||
|
.findBy({ userId: In(moderators.map(it => it.id)) })
|
||||||
|
.then(it => new Map(it.map(it => [it.userId, it])));
|
||||||
|
|
||||||
|
const mail = generateInvitationOnlyChangedMail();
|
||||||
|
for (const moderator of moderators) {
|
||||||
|
this.notificationService.createNotification(
|
||||||
|
moderator.id,
|
||||||
|
'adminInactiveModeratorsInvitationOnlyChanged',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = moderatorProfiles.get(moderator.id);
|
||||||
|
if (profile && profile.email && profile.emailVerified) {
|
||||||
|
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text).then();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- SystemWebhook
|
||||||
|
|
||||||
|
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
|
||||||
|
.then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged')));
|
||||||
|
for (const systemWebhook of systemWebhooks) {
|
||||||
|
this.systemWebhookService.enqueueSystemWebhook(
|
||||||
|
systemWebhook,
|
||||||
|
'inactiveModeratorsInvitationOnlyChanged',
|
||||||
|
{},
|
||||||
|
).then();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async fetchModerators() {
|
private async fetchModerators() {
|
||||||
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
|
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
|
||||||
|
|
|
@ -19,6 +19,8 @@
|
||||||
* exportCompleted - エクスポートが完了
|
* exportCompleted - エクスポートが完了
|
||||||
* login - ログイン
|
* login - ログイン
|
||||||
* app - アプリ通知
|
* app - アプリ通知
|
||||||
|
* adminInactiveModeratorsWarning - [モデレータ以上向け] モデレータの不活性に対する警告
|
||||||
|
* adminInactiveModeratorsInvitationOnlyChanged - [モデレータ以上向け] モデレータが不活性のため招待制に変更された
|
||||||
* test - テスト通知(サーバー側)
|
* test - テスト通知(サーバー側)
|
||||||
*/
|
*/
|
||||||
export const notificationTypes = [
|
export const notificationTypes = [
|
||||||
|
@ -37,6 +39,8 @@ export const notificationTypes = [
|
||||||
'exportCompleted',
|
'exportCompleted',
|
||||||
'login',
|
'login',
|
||||||
'app',
|
'app',
|
||||||
|
'adminInactiveModeratorsWarning',
|
||||||
|
'adminInactiveModeratorsInvitationOnlyChanged',
|
||||||
'test',
|
'test',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
@ -8,13 +8,16 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import * as lolex from '@sinonjs/fake-timers';
|
import * as lolex from '@sinonjs/fake-timers';
|
||||||
import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns';
|
import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns';
|
||||||
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||||
import { MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { GlobalModule } from '@/GlobalModule.js';
|
import { GlobalModule } from '@/GlobalModule.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
|
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
|
||||||
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
|
import { EmailService } from '@/core/EmailService.js';
|
||||||
|
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||||
|
|
||||||
const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0));
|
const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0));
|
||||||
|
|
||||||
|
@ -29,10 +32,17 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
let userProfilesRepository: UserProfilesRepository;
|
let userProfilesRepository: UserProfilesRepository;
|
||||||
let idService: IdService;
|
let idService: IdService;
|
||||||
let roleService: jest.Mocked<RoleService>;
|
let roleService: jest.Mocked<RoleService>;
|
||||||
|
let notificationService: jest.Mocked<NotificationService>;
|
||||||
|
let emailService: jest.Mocked<EmailService>;
|
||||||
|
let systemWebhookService: jest.Mocked<SystemWebhookService>;
|
||||||
|
|
||||||
|
let systemWebhook1: MiSystemWebhook;
|
||||||
|
let systemWebhook2: MiSystemWebhook;
|
||||||
|
let systemWebhook3: MiSystemWebhook;
|
||||||
|
|
||||||
// --------------------------------------------------------------------------------------
|
// --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
async function createUser(data: Partial<MiUser> = {}) {
|
async function createUser(data: Partial<MiUser> = {}, profile: Partial<MiUserProfile> = {}): Promise<MiUser> {
|
||||||
const id = idService.gen();
|
const id = idService.gen();
|
||||||
const user = await usersRepository
|
const user = await usersRepository
|
||||||
.insert({
|
.insert({
|
||||||
|
@ -45,11 +55,27 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
|
|
||||||
await userProfilesRepository.insert({
|
await userProfilesRepository.insert({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
...profile,
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function crateSystemWebhook(data: Partial<MiSystemWebhook> = {}): MiSystemWebhook {
|
||||||
|
return {
|
||||||
|
id: idService.gen(),
|
||||||
|
isActive: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
latestSentAt: null,
|
||||||
|
latestStatus: null,
|
||||||
|
name: 'test',
|
||||||
|
url: 'https://example.com',
|
||||||
|
secret: 'test',
|
||||||
|
on: [],
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mockModeratorRole(users: MiUser[]) {
|
function mockModeratorRole(users: MiUser[]) {
|
||||||
roleService.getModerators.mockReset();
|
roleService.getModerators.mockReset();
|
||||||
roleService.getModerators.mockResolvedValue(users);
|
roleService.getModerators.mockResolvedValue(users);
|
||||||
|
@ -72,6 +98,18 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
{
|
{
|
||||||
provide: MetaService, useFactory: () => ({ fetch: jest.fn() }),
|
provide: MetaService, useFactory: () => ({ fetch: jest.fn() }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: NotificationService, useFactory: () => ({ createNotification: jest.fn() }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SystemWebhookService, useFactory: () => ({
|
||||||
|
fetchActiveSystemWebhooks: jest.fn(),
|
||||||
|
enqueueSystemWebhook: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: QueueLoggerService, useFactory: () => ({
|
provide: QueueLoggerService, useFactory: () => ({
|
||||||
logger: ({
|
logger: ({
|
||||||
|
@ -93,6 +131,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
service = app.get(CheckModeratorsActivityProcessorService);
|
service = app.get(CheckModeratorsActivityProcessorService);
|
||||||
idService = app.get(IdService);
|
idService = app.get(IdService);
|
||||||
roleService = app.get(RoleService) as jest.Mocked<RoleService>;
|
roleService = app.get(RoleService) as jest.Mocked<RoleService>;
|
||||||
|
notificationService = app.get(NotificationService) as jest.Mocked<NotificationService>;
|
||||||
|
emailService = app.get(EmailService) as jest.Mocked<EmailService>;
|
||||||
|
systemWebhookService = app.get(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
|
||||||
|
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
});
|
});
|
||||||
|
@ -102,6 +143,14 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
now: new Date(baseDate),
|
now: new Date(baseDate),
|
||||||
shouldClearNativeTimers: true,
|
shouldClearNativeTimers: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
systemWebhook1 = crateSystemWebhook({ on: ['inactiveModeratorsWarning'] });
|
||||||
|
systemWebhook2 = crateSystemWebhook({ on: ['inactiveModeratorsWarning', 'inactiveModeratorsInvitationOnlyChanged'] });
|
||||||
|
systemWebhook3 = crateSystemWebhook({ on: ['abuseReport'] });
|
||||||
|
|
||||||
|
emailService.sendEmail.mockReturnValue(Promise.resolve());
|
||||||
|
systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]);
|
||||||
|
systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
@ -109,6 +158,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
await usersRepository.delete({});
|
await usersRepository.delete({});
|
||||||
await userProfilesRepository.delete({});
|
await userProfilesRepository.delete({});
|
||||||
roleService.getModerators.mockReset();
|
roleService.getModerators.mockReset();
|
||||||
|
notificationService.createNotification.mockReset();
|
||||||
|
emailService.sendEmail.mockReset();
|
||||||
|
systemWebhookService.enqueueSystemWebhook.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -152,7 +204,7 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
expect(result.inactiveModerators).toEqual([user1]);
|
expect(result.inactiveModerators).toEqual([user1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[countdown] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
|
test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
|
||||||
const [user1, user2] = await Promise.all([
|
const [user1, user2] = await Promise.all([
|
||||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||||
// 猶予はこのユーザ基準で計算される想定。
|
// 猶予はこのユーザ基準で計算される想定。
|
||||||
|
@ -165,10 +217,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
const result = await service.evaluateModeratorsInactiveDays();
|
const result = await service.evaluateModeratorsInactiveDays();
|
||||||
expect(result.isModeratorsInactive).toBe(false);
|
expect(result.isModeratorsInactive).toBe(false);
|
||||||
expect(result.inactiveModerators).toEqual([user1]);
|
expect(result.inactiveModerators).toEqual([user1]);
|
||||||
expect(result.inactivityLimitCountdown).toBe(1);
|
expect(result.remainingTime.asDays).toBe(1);
|
||||||
|
expect(result.remainingTime.asHours).toBe(24);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[countdown] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
|
test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
|
||||||
const [user1, user2] = await Promise.all([
|
const [user1, user2] = await Promise.all([
|
||||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||||
// 猶予はこのユーザ基準で計算される想定。
|
// 猶予はこのユーザ基準で計算される想定。
|
||||||
|
@ -181,10 +234,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
const result = await service.evaluateModeratorsInactiveDays();
|
const result = await service.evaluateModeratorsInactiveDays();
|
||||||
expect(result.isModeratorsInactive).toBe(false);
|
expect(result.isModeratorsInactive).toBe(false);
|
||||||
expect(result.inactiveModerators).toEqual([user1]);
|
expect(result.inactiveModerators).toEqual([user1]);
|
||||||
expect(result.inactivityLimitCountdown).toBe(1);
|
expect(result.remainingTime.asDays).toBe(1);
|
||||||
|
expect(result.remainingTime.asHours).toBe(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[countdown] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
|
test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
|
||||||
const [user1, user2] = await Promise.all([
|
const [user1, user2] = await Promise.all([
|
||||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||||
// 猶予はこのユーザ基準で計算される想定。
|
// 猶予はこのユーザ基準で計算される想定。
|
||||||
|
@ -197,10 +251,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
const result = await service.evaluateModeratorsInactiveDays();
|
const result = await service.evaluateModeratorsInactiveDays();
|
||||||
expect(result.isModeratorsInactive).toBe(false);
|
expect(result.isModeratorsInactive).toBe(false);
|
||||||
expect(result.inactiveModerators).toEqual([user1]);
|
expect(result.inactiveModerators).toEqual([user1]);
|
||||||
expect(result.inactivityLimitCountdown).toBe(0);
|
expect(result.remainingTime.asDays).toBe(0);
|
||||||
|
expect(result.remainingTime.asHours).toBe(23);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[countdown] 期限ちょうどの場合、猶予0日として計算される', async () => {
|
test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => {
|
||||||
const [user1, user2] = await Promise.all([
|
const [user1, user2] = await Promise.all([
|
||||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||||
// 猶予はこのユーザ基準で計算される想定。
|
// 猶予はこのユーザ基準で計算される想定。
|
||||||
|
@ -213,10 +268,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
const result = await service.evaluateModeratorsInactiveDays();
|
const result = await service.evaluateModeratorsInactiveDays();
|
||||||
expect(result.isModeratorsInactive).toBe(false);
|
expect(result.isModeratorsInactive).toBe(false);
|
||||||
expect(result.inactiveModerators).toEqual([user1]);
|
expect(result.inactiveModerators).toEqual([user1]);
|
||||||
expect(result.inactivityLimitCountdown).toBe(0);
|
expect(result.remainingTime.asDays).toBe(0);
|
||||||
|
expect(result.remainingTime.asHours).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[countdown] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
|
test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
|
||||||
const [user1, user2] = await Promise.all([
|
const [user1, user2] = await Promise.all([
|
||||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||||
// 猶予はこのユーザ基準で計算される想定。
|
// 猶予はこのユーザ基準で計算される想定。
|
||||||
|
@ -229,7 +285,100 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
||||||
const result = await service.evaluateModeratorsInactiveDays();
|
const result = await service.evaluateModeratorsInactiveDays();
|
||||||
expect(result.isModeratorsInactive).toBe(true);
|
expect(result.isModeratorsInactive).toBe(true);
|
||||||
expect(result.inactiveModerators).toEqual([user1, user2]);
|
expect(result.inactiveModerators).toEqual([user1, user2]);
|
||||||
expect(result.inactivityLimitCountdown).toBe(-1);
|
expect(result.remainingTime.asDays).toBe(-1);
|
||||||
|
expect(result.remainingTime.asHours).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[remainingTime] 期限より25時間超過している場合、猶予-2日として計算される', async () => {
|
||||||
|
const [user1, user2] = await Promise.all([
|
||||||
|
createUser({ lastActiveDate: subDays(baseDate, 10) }),
|
||||||
|
// 猶予はこのユーザ基準で計算される想定。
|
||||||
|
// 期限より1時間超過->猶予-1日として計算されるはずである
|
||||||
|
createUser({ lastActiveDate: subDays(subHours(baseDate, 25), 7) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockModeratorRole([user1, user2]);
|
||||||
|
|
||||||
|
const result = await service.evaluateModeratorsInactiveDays();
|
||||||
|
expect(result.isModeratorsInactive).toBe(true);
|
||||||
|
expect(result.inactiveModerators).toEqual([user1, user2]);
|
||||||
|
expect(result.remainingTime.asDays).toBe(-2);
|
||||||
|
expect(result.remainingTime.asHours).toBe(-25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notifyInactiveModeratorsWarning', () => {
|
||||||
|
test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
|
||||||
|
const [user1, user2, user3, user4, root] = await Promise.all([
|
||||||
|
createUser({}, { email: 'user1@example.com', emailVerified: true }),
|
||||||
|
createUser({}, { email: 'user2@example.com', emailVerified: false }),
|
||||||
|
createUser({}, { email: null, emailVerified: false }),
|
||||||
|
createUser({}, { email: 'user4@example.com', emailVerified: true }),
|
||||||
|
createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockModeratorRole([user1, user2, user3, root]);
|
||||||
|
await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
|
||||||
|
|
||||||
|
expect(notificationService.createNotification).toHaveBeenCalledTimes(4);
|
||||||
|
expect(notificationService.createNotification.mock.calls[0][0]).toBe(user1.id);
|
||||||
|
expect(notificationService.createNotification.mock.calls[1][0]).toBe(user2.id);
|
||||||
|
expect(notificationService.createNotification.mock.calls[2][0]).toBe(user3.id);
|
||||||
|
expect(notificationService.createNotification.mock.calls[3][0]).toBe(root.id);
|
||||||
|
|
||||||
|
expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
|
||||||
|
expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
|
||||||
|
expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[systemWebhook] "inactiveModeratorsWarning"が有効なSystemWebhookに対して送信される', async () => {
|
||||||
|
const [user1] = await Promise.all([
|
||||||
|
createUser({}, { email: 'user1@example.com', emailVerified: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockModeratorRole([user1]);
|
||||||
|
await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
|
||||||
|
|
||||||
|
expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(2);
|
||||||
|
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook1);
|
||||||
|
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[1][0]).toEqual(systemWebhook2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notifyChangeToInvitationOnly', () => {
|
||||||
|
test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
|
||||||
|
const [user1, user2, user3, user4, root] = await Promise.all([
|
||||||
|
createUser({}, { email: 'user1@example.com', emailVerified: true }),
|
||||||
|
createUser({}, { email: 'user2@example.com', emailVerified: false }),
|
||||||
|
createUser({}, { email: null, emailVerified: false }),
|
||||||
|
createUser({}, { email: 'user4@example.com', emailVerified: true }),
|
||||||
|
createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockModeratorRole([user1, user2, user3, root]);
|
||||||
|
await service.notifyChangeToInvitationOnly();
|
||||||
|
|
||||||
|
expect(notificationService.createNotification).toHaveBeenCalledTimes(4);
|
||||||
|
expect(notificationService.createNotification.mock.calls[0][0]).toBe(user1.id);
|
||||||
|
expect(notificationService.createNotification.mock.calls[1][0]).toBe(user2.id);
|
||||||
|
expect(notificationService.createNotification.mock.calls[2][0]).toBe(user3.id);
|
||||||
|
expect(notificationService.createNotification.mock.calls[3][0]).toBe(root.id);
|
||||||
|
|
||||||
|
expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
|
||||||
|
expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
|
||||||
|
expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[systemWebhook] "inactiveModeratorsInvitationOnlyChanged"が有効なSystemWebhookに対して送信される', async () => {
|
||||||
|
const [user1] = await Promise.all([
|
||||||
|
createUser({}, { email: 'user1@example.com', emailVerified: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockModeratorRole([user1]);
|
||||||
|
await service.notifyChangeToInvitationOnly();
|
||||||
|
|
||||||
|
expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1);
|
||||||
|
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 23 KiB |
|
@ -11,6 +11,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
|
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
|
||||||
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
|
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
|
||||||
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
|
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
|
||||||
|
<img v-else-if="notification.type === 'adminInactiveModeratorsWarning'" :class="$style.icon" alt="" :src="'/client-assets/mi.png'"/>
|
||||||
|
<img v-else-if="notification.type === 'adminInactiveModeratorsInvitationOnlyChanged'" :class="$style.icon" alt="" :src="'/client-assets/mi.png'"/>
|
||||||
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
|
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
|
||||||
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
|
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
|
||||||
<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
|
<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
|
||||||
|
@ -67,6 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
|
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
|
||||||
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
|
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
|
||||||
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
|
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
|
||||||
|
<span v-else-if="notification.type === 'adminInactiveModeratorsWarning'">{{ i18n.ts._notification.fromSystem }}</span>
|
||||||
|
<span v-else-if="notification.type === 'adminInactiveModeratorsInvitationOnlyChanged'">{{ i18n.ts._notification.fromSystem }}</span>
|
||||||
<span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
|
<span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
|
||||||
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
|
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
|
||||||
</header>
|
</header>
|
||||||
|
@ -130,6 +134,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<Mfm :text="notification.body" :nowrap="false"/>
|
<Mfm :text="notification.body" :nowrap="false"/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span v-else-if="notification.type === 'adminInactiveModeratorsWarning'" :class="$style.text">
|
||||||
|
{{
|
||||||
|
i18n.tsx._notification.adminInactiveModeratorsWarning({
|
||||||
|
timeVariant: notification.remainingTime.asDays === 0
|
||||||
|
? i18n.tsx._timeIn.hours({ n: notification.remainingTime.asHours })
|
||||||
|
: i18n.tsx._timeIn.days({ n: notification.remainingTime.asDays })
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="notification.type === 'adminInactiveModeratorsInvitationOnlyChanged'" :class="$style.text">
|
||||||
|
{{ i18n.ts._notification.adminInactiveModeratorsInvitationOnlyChanged }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div v-if="notification.type === 'reaction:grouped'">
|
<div v-if="notification.type === 'reaction:grouped'">
|
||||||
<div v-for="reaction of notification.reactions" :key="reaction.user.id + reaction.reaction" :class="$style.reactionsItem">
|
<div v-for="reaction of notification.reactions" :key="reaction.user.id + reaction.reaction" :class="$style.reactionsItem">
|
||||||
<MkAvatar :class="$style.reactionsItemAvatar" :user="reaction.user" link preview/>
|
<MkAvatar :class="$style.reactionsItemAvatar" :user="reaction.user" link preview/>
|
||||||
|
|
|
@ -55,6 +55,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.userCreated)" @click="test('userCreated')"><i class="ti ti-send"></i></MkButton>
|
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.userCreated)" @click="test('userCreated')"><i class="ti ti-send"></i></MkButton>
|
||||||
</div>
|
</div>
|
||||||
|
<div :class="$style.switchBox">
|
||||||
|
<MkSwitch v-model="events.inactiveModeratorsWarning" :disabled="disabledEvents.inactiveModeratorsWarning">
|
||||||
|
<template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsWarning }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsWarning)" @click="test('inactiveModeratorsWarning')"><i class="ti ti-send"></i></MkButton>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.switchBox">
|
||||||
|
<MkSwitch v-model="events.inactiveModeratorsInvitationOnlyChanged" :disabled="disabledEvents.inactiveModeratorsInvitationOnlyChanged">
|
||||||
|
<template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsInvitationOnlyChanged }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsInvitationOnlyChanged)" @click="test('inactiveModeratorsInvitationOnlyChanged')"><i class="ti ti-send"></i></MkButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="mode === 'edit'" :class="$style.description">
|
<div v-show="mode === 'edit'" :class="$style.description">
|
||||||
|
@ -100,6 +112,8 @@ type EventType = {
|
||||||
abuseReport: boolean;
|
abuseReport: boolean;
|
||||||
abuseReportResolved: boolean;
|
abuseReportResolved: boolean;
|
||||||
userCreated: boolean;
|
userCreated: boolean;
|
||||||
|
inactiveModeratorsWarning: boolean;
|
||||||
|
inactiveModeratorsInvitationOnlyChanged: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -123,6 +137,8 @@ const events = ref<EventType>({
|
||||||
abuseReport: true,
|
abuseReport: true,
|
||||||
abuseReportResolved: true,
|
abuseReportResolved: true,
|
||||||
userCreated: true,
|
userCreated: true,
|
||||||
|
inactiveModeratorsWarning: true,
|
||||||
|
inactiveModeratorsInvitationOnlyChanged: true,
|
||||||
});
|
});
|
||||||
const isActive = ref<boolean>(true);
|
const isActive = ref<boolean>(true);
|
||||||
|
|
||||||
|
@ -130,6 +146,8 @@ const disabledEvents = ref<EventType>({
|
||||||
abuseReport: false,
|
abuseReport: false,
|
||||||
abuseReportResolved: false,
|
abuseReportResolved: false,
|
||||||
userCreated: false,
|
userCreated: false,
|
||||||
|
inactiveModeratorsWarning: false,
|
||||||
|
inactiveModeratorsInvitationOnlyChanged: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const disableSubmitButton = computed(() => {
|
const disableSubmitButton = computed(() => {
|
||||||
|
|
|
@ -4347,6 +4347,25 @@ export type components = {
|
||||||
type: 'renote:grouped';
|
type: 'renote:grouped';
|
||||||
note: components['schemas']['Note'];
|
note: components['schemas']['Note'];
|
||||||
users: components['schemas']['UserLite'][];
|
users: components['schemas']['UserLite'][];
|
||||||
|
} | {
|
||||||
|
/** Format: id */
|
||||||
|
id: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: 'adminInactiveModeratorsWarning';
|
||||||
|
remainingTime: {
|
||||||
|
time: number;
|
||||||
|
asDays: number;
|
||||||
|
asHours: number;
|
||||||
|
};
|
||||||
|
} | {
|
||||||
|
/** Format: id */
|
||||||
|
id: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: 'adminInactiveModeratorsInvitationOnlyChanged';
|
||||||
} | {
|
} | {
|
||||||
/** Format: id */
|
/** Format: id */
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -5047,7 +5066,7 @@ export type components = {
|
||||||
latestSentAt: string | null;
|
latestSentAt: string | null;
|
||||||
latestStatus: number | null;
|
latestStatus: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
|
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
|
||||||
url: string;
|
url: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
};
|
};
|
||||||
|
@ -10242,7 +10261,7 @@ export type operations = {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
|
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
|
||||||
url: string;
|
url: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
};
|
};
|
||||||
|
@ -10352,7 +10371,7 @@ export type operations = {
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
|
on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -10465,7 +10484,7 @@ export type operations = {
|
||||||
id: string;
|
id: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
|
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
|
||||||
url: string;
|
url: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
};
|
};
|
||||||
|
@ -10524,7 +10543,7 @@ export type operations = {
|
||||||
/** Format: misskey:id */
|
/** Format: misskey:id */
|
||||||
webhookId: string;
|
webhookId: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
type: 'abuseReport' | 'abuseReportResolved' | 'userCreated';
|
type: 'abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged';
|
||||||
override?: {
|
override?: {
|
||||||
url?: string;
|
url?: string;
|
||||||
secret?: string;
|
secret?: string;
|
||||||
|
@ -18686,8 +18705,8 @@ export type operations = {
|
||||||
untilId?: string;
|
untilId?: string;
|
||||||
/** @default true */
|
/** @default true */
|
||||||
markAsRead?: boolean;
|
markAsRead?: boolean;
|
||||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'adminInactiveModeratorsWarning' | 'adminInactiveModeratorsInvitationOnlyChanged' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'adminInactiveModeratorsWarning' | 'adminInactiveModeratorsInvitationOnlyChanged' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -18754,8 +18773,8 @@ export type operations = {
|
||||||
untilId?: string;
|
untilId?: string;
|
||||||
/** @default true */
|
/** @default true */
|
||||||
markAsRead?: boolean;
|
markAsRead?: boolean;
|
||||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'adminInactiveModeratorsWarning' | 'adminInactiveModeratorsInvitationOnlyChanged' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
||||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'adminInactiveModeratorsWarning' | 'adminInactiveModeratorsInvitationOnlyChanged' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -16,7 +16,7 @@ import type {
|
||||||
UserLite,
|
UserLite,
|
||||||
} from './autogen/models.js';
|
} from './autogen/models.js';
|
||||||
|
|
||||||
export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned'] as const;
|
export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'adminInactiveModeratorsWarning', 'adminInactiveModeratorsInvitationOnlyChanged'] as const;
|
||||||
|
|
||||||
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
|
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue