From a54de07260c3555d0230492970448604ffb9d586 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Tue, 18 Feb 2020 08:41:32 +0900
Subject: [PATCH] Resolve #5963

---
 locales/ja-JP.yml                             |  3 +
 migration/1581979837262-promo.ts              | 28 +++++++++
 src/client/components/date-separated-list.vue |  2 +-
 src/client/components/note.vue                | 38 +++++++++++-
 src/client/components/notes.vue               |  2 +-
 src/db/postgre.ts                             |  4 ++
 src/models/entities/promo-note.ts             | 28 +++++++++
 src/models/entities/promo-read.ts             | 35 +++++++++++
 src/models/index.ts                           |  4 ++
 src/models/repositories/note.ts               |  1 +
 src/server/api/common/inject-promo.ts         | 36 ++++++++++++
 .../api/endpoints/admin/promo/create.ts       | 58 +++++++++++++++++++
 .../api/endpoints/notes/global-timeline.ts    |  4 +-
 .../api/endpoints/notes/hybrid-timeline.ts    |  3 +
 .../api/endpoints/notes/local-timeline.ts     |  3 +
 src/server/api/endpoints/notes/timeline.ts    |  3 +
 16 files changed, 247 insertions(+), 5 deletions(-)
 create mode 100644 migration/1581979837262-promo.ts
 create mode 100644 src/models/entities/promo-note.ts
 create mode 100644 src/models/entities/promo-read.ts
 create mode 100644 src/server/api/common/inject-promo.ts
 create mode 100644 src/server/api/endpoints/admin/promo/create.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 059ae7e60f..288ffd39fb 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -412,6 +412,9 @@ dayOverDayChanges: "前日比"
 accessibility: "アクセシビリティ"
 clinetSettings: "クライアント設定"
 accountSettings: "アカウント設定"
+promotion: "プロモーション"
+promote: "プロモート"
+numberOfDays: "日数"
 
 _ago:
   unknown: "謎"
diff --git a/migration/1581979837262-promo.ts b/migration/1581979837262-promo.ts
new file mode 100644
index 0000000000..2c4f25c4d7
--- /dev/null
+++ b/migration/1581979837262-promo.ts
@@ -0,0 +1,28 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class promo1581979837262 implements MigrationInterface {
+    name = 'promo1581979837262'
+
+    public async up(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`CREATE TABLE "promo_note" ("noteId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "REL_e263909ca4fe5d57f8d4230dd5" UNIQUE ("noteId"), CONSTRAINT "PK_e263909ca4fe5d57f8d4230dd5c" PRIMARY KEY ("noteId"))`, undefined);
+        await queryRunner.query(`CREATE INDEX "IDX_83f0862e9bae44af52ced7099e" ON "promo_note" ("userId") `, undefined);
+        await queryRunner.query(`CREATE TABLE "promo_read" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_61917c1541002422b703318b7c9" PRIMARY KEY ("id"))`, undefined);
+        await queryRunner.query(`CREATE INDEX "IDX_9657d55550c3d37bfafaf7d4b0" ON "promo_read" ("userId") `, undefined);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_2882b8a1a07c7d281a98b6db16" ON "promo_read" ("userId", "noteId") `, undefined);
+        await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
+        await queryRunner.query(`ALTER TABLE "promo_read" ADD CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
+        await queryRunner.query(`ALTER TABLE "promo_read" ADD CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`ALTER TABLE "promo_read" DROP CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4"`, undefined);
+        await queryRunner.query(`ALTER TABLE "promo_read" DROP CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05"`, undefined);
+        await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c"`, undefined);
+        await queryRunner.query(`DROP INDEX "IDX_2882b8a1a07c7d281a98b6db16"`, undefined);
+        await queryRunner.query(`DROP INDEX "IDX_9657d55550c3d37bfafaf7d4b0"`, undefined);
+        await queryRunner.query(`DROP TABLE "promo_read"`, undefined);
+        await queryRunner.query(`DROP INDEX "IDX_83f0862e9bae44af52ced7099e"`, undefined);
+        await queryRunner.query(`DROP TABLE "promo_note"`, undefined);
+    }
+
+}
diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue
index 461459f3ba..c425c02dce 100644
--- a/src/client/components/date-separated-list.vue
+++ b/src/client/components/date-separated-list.vue
@@ -2,7 +2,7 @@
 <sequential-entrance class="sqadhkmv" ref="list" :direction="direction" :reversed="reversed">
 	<template v-for="(item, i) in items">
 		<slot :item="item" :i="i"></slot>
-		<div class="separator" :key="item.id + '_date'" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate()">
+		<div class="separator" :key="item.id + '_date'" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate() && !item._prInjectionId_ && !items[i + 1]._prInjectionId_">
 			<p class="date">
 				<span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span>
 				<span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span>
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index 963e2045f1..e6b522d8d0 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -10,6 +10,7 @@
 	<x-sub v-for="note in conversation" :key="note.id" :note="note"/>
 	<x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
 	<div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div>
+	<div class="pinned" v-if="appearNote._prInjectionId_"><fa :icon="faBullhorn"/> {{ $t('promotion') }}</div>
 	<div class="renote" v-if="isRenote">
 		<mk-avatar class="avatar" :user="note.user"/>
 		<fa :icon="faRetweet"/>
@@ -83,7 +84,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight } from '@fortawesome/free-solid-svg-icons';
+import { faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight } from '@fortawesome/free-solid-svg-icons';
 import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
 import { parse } from '../../mfm/parse';
 import { sum, unique } from '../../prelude/array';
@@ -140,7 +141,7 @@ export default Vue.extend({
 			replies: [],
 			showContent: false,
 			hideThisNote: false,
-			faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan
+			faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan
 		};
 	},
 
@@ -522,6 +523,15 @@ export default Vue.extend({
 					text: this.$t('pin'),
 					action: () => this.togglePin(true)
 				} : undefined,
+				...(this.$store.state.i.isModerator || this.$store.state.i.isAdmin ? [
+					null,
+					{
+						icon: faBullhorn,
+						text: this.$t('promote'),
+						action: this.promote
+					}]
+					: []
+				),
 				...(this.appearNote.userId == this.$store.state.i.id ? [
 					null,
 					{
@@ -614,6 +624,30 @@ export default Vue.extend({
 			});
 		},
 
+		async promote() {
+			const { canceled, result: days } = await this.$root.dialog({
+				title: this.$t('numberOfDays'),
+				input: { type: 'number' }
+			});
+
+			if (canceled) return;
+
+			this.$root.api('admin/promo/create', {
+				noteId: this.appearNote.id,
+				expiresAt: Date.now() + (86400000 * days)
+			}).then(() => {
+				this.$root.dialog({
+					type: 'success',
+					iconOnly: true, autoClose: true
+				});
+			}).catch(e => {
+				this.$root.dialog({
+					type: 'error',
+					text: e
+				});
+			});
+		},
+
 		focus() {
 			this.$el.focus();
 		},
diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue
index fb3a4314ba..2bf6327b09 100644
--- a/src/client/components/notes.vue
+++ b/src/client/components/notes.vue
@@ -15,7 +15,7 @@
 	</div>
 
 	<x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed">
-		<x-note :note="note" :detail="detail" :key="note.id"/>
+		<x-note :note="note" :detail="detail" :key="note._prInjectionId_ || note.id"/>
 	</x-list>
 
 	<div class="more" v-if="more && !reversed" style="margin-top: var(--margin);">
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index 38c7794402..021fe9ef69 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -55,6 +55,8 @@ import { Clip } from '../models/entities/clip';
 import { ClipNote } from '../models/entities/clip-note';
 import { Antenna } from '../models/entities/antenna';
 import { AntennaNote } from '../models/entities/antenna-note';
+import { PromoNote } from '../models/entities/promo-note';
+import { PromoRead } from '../models/entities/promo-read';
 
 const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
 
@@ -140,6 +142,8 @@ export const entities = [
 	ClipNote,
 	Antenna,
 	AntennaNote,
+	PromoNote,
+	PromoRead,
 	ReversiGame,
 	ReversiMatching,
 	...charts as any
diff --git a/src/models/entities/promo-note.ts b/src/models/entities/promo-note.ts
new file mode 100644
index 0000000000..474f1cb235
--- /dev/null
+++ b/src/models/entities/promo-note.ts
@@ -0,0 +1,28 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm';
+import { Note } from './note';
+import { User } from './user';
+import { id } from '../id';
+
+@Entity()
+export class PromoNote {
+	@PrimaryColumn(id())
+	public noteId: Note['id'];
+
+	@OneToOne(type => Note, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public note: Note | null;
+
+	@Column('timestamp with time zone')
+	public expiresAt: Date;
+
+	//#region Denormalized fields
+	@Index()
+	@Column({
+		...id(),
+		comment: '[Denormalized]'
+	})
+	public userId: User['id'];
+	//#endregion
+}
diff --git a/src/models/entities/promo-read.ts b/src/models/entities/promo-read.ts
new file mode 100644
index 0000000000..2e0977b6b5
--- /dev/null
+++ b/src/models/entities/promo-read.ts
@@ -0,0 +1,35 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { Note } from './note';
+import { User } from './user';
+import { id } from '../id';
+
+@Entity()
+@Index(['userId', 'noteId'], { unique: true })
+export class PromoRead {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the PromoRead.'
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column(id())
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public user: User | null;
+
+	@Column(id())
+	public noteId: Note['id'];
+
+	@ManyToOne(type => Note, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public note: Note | null;
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index ea8fa6f911..39f185e6f4 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -50,6 +50,8 @@ import { ClipRepository } from './repositories/clip';
 import { ClipNote } from './entities/clip-note';
 import { AntennaRepository } from './repositories/antenna';
 import { AntennaNote } from './entities/antenna-note';
+import { PromoNote } from './entities/promo-note';
+import { PromoRead } from './entities/promo-read';
 
 export const Announcements = getRepository(Announcement);
 export const AnnouncementReads = getRepository(AnnouncementRead);
@@ -102,3 +104,5 @@ export const Clips = getCustomRepository(ClipRepository);
 export const ClipNotes = getRepository(ClipNote);
 export const Antennas = getCustomRepository(AntennaRepository);
 export const AntennaNotes = getRepository(AntennaNote);
+export const PromoNotes = getRepository(PromoNote);
+export const PromoReads = getRepository(PromoRead);
diff --git a/src/models/repositories/note.ts b/src/models/repositories/note.ts
index 3f7295bbdb..5d0a8768d1 100644
--- a/src/models/repositories/note.ts
+++ b/src/models/repositories/note.ts
@@ -196,6 +196,7 @@ export class NoteRepository extends Repository<Note> {
 			renoteId: note.renoteId,
 			mentions: note.mentions.length > 0 ? note.mentions : undefined,
 			uri: note.uri || undefined,
+			_prInjectionId_: (note as any)._prInjectionId_ || undefined,
 
 			...(opts.detail ? {
 				reply: note.replyId ? this.pack(note.replyId, meId, {
diff --git a/src/server/api/common/inject-promo.ts b/src/server/api/common/inject-promo.ts
new file mode 100644
index 0000000000..785d7af085
--- /dev/null
+++ b/src/server/api/common/inject-promo.ts
@@ -0,0 +1,36 @@
+import rndstr from 'rndstr';
+import { Note } from '../../../models/entities/note';
+import { User } from '../../../models/entities/user';
+import { PromoReads, PromoNotes, Notes, Users } from '../../../models';
+import { ensure } from '../../../prelude/ensure';
+
+export async function injectPromo(user: User, timeline: Note[]) {
+	if (timeline.length < 5) return;
+
+	// TODO: readやexpireフィルタはクエリ側でやる
+
+	const reads = await PromoReads.find({
+		userId: user.id
+	});
+
+	let promos = await PromoNotes.find();
+
+	promos = promos.filter(n => n.expiresAt.getTime() > Date.now());
+	promos = promos.filter(n => !reads.map(r => r.noteId).includes(n.noteId));
+
+	if (promos.length === 0) return;
+
+	const promo = promos[Math.floor(Math.random() * promos.length)];
+
+	// Pick random promo
+	const note = await Notes.findOne(promo.noteId).then(ensure);
+
+	// Join
+	note.user = await Users.findOne(note.userId).then(ensure);
+
+	(note as any)._prInjectionId_ = rndstr('a-z0-9', 8);
+
+	// Inject promo
+	timeline.splice(3, 0, note);
+	timeline.pop();
+}
diff --git a/src/server/api/endpoints/admin/promo/create.ts b/src/server/api/endpoints/admin/promo/create.ts
new file mode 100644
index 0000000000..50fbb6563c
--- /dev/null
+++ b/src/server/api/endpoints/admin/promo/create.ts
@@ -0,0 +1,58 @@
+import $ from 'cafy';
+import { ID } from '../../../../../misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getNote } from '../../../common/getters';
+import { PromoNotes } from '../../../../../models';
+
+export const meta = {
+	requireCredential: true as const,
+	requireModerator: true,
+
+	params: {
+		noteId: {
+			validator: $.type(ID),
+		},
+
+		expiresAt: {
+			validator: $.num.int()
+		},
+	},
+
+	errors: {
+		noSuchNote: {
+			message: 'No such note.',
+			code: 'NO_SUCH_NOTE',
+			id: 'ee449fbe-af2a-453b-9cae-cf2fe7c895fc'
+		},
+
+		alreadyPromoted: {
+			message: 'The note has already promoted.',
+			code: 'ALREADY_PROMOTED',
+			id: 'ae427aa2-7a41-484f-a18c-2c1104051604'
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	// Get favoritee
+	const note = await getNote(ps.noteId).catch(e => {
+		if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+		throw e;
+	});
+
+	// if already favorited
+	const exist = await PromoNotes.findOne(note.id);
+
+	if (exist != null) {
+		throw new ApiError(meta.errors.alreadyPromoted);
+	}
+
+	// Create favorite
+	await PromoNotes.save({
+		noteId: note.id,
+		createdAt: new Date(),
+		expiresAt: new Date(ps.expiresAt),
+		userId: note.userId,
+	});
+});
diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts
index 7475c8f078..0f69896de2 100644
--- a/src/server/api/endpoints/notes/global-timeline.ts
+++ b/src/server/api/endpoints/notes/global-timeline.ts
@@ -7,8 +7,8 @@ import { makePaginationQuery } from '../../common/make-pagination-query';
 import { Notes } from '../../../../models';
 import { generateMuteQuery } from '../../common/generate-mute-query';
 import { activeUsersChart } from '../../../../services/chart';
-import { Brackets } from 'typeorm';
 import { generateRepliesQuery } from '../../common/generate-replies-query';
+import { injectPromo } from '../../common/inject-promo';
 
 export const meta = {
 	desc: {
@@ -90,6 +90,8 @@ export default define(meta, async (ps, user) => {
 
 	const timeline = await query.take(ps.limit!).getMany();
 
+	await injectPromo(user, timeline);
+
 	process.nextTick(() => {
 		if (user) {
 			activeUsersChart.update(user);
diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts
index 5aa18b2e91..f30fbab8a1 100644
--- a/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -10,6 +10,7 @@ import { generateVisibilityQuery } from '../../common/generate-visibility-query'
 import { generateMuteQuery } from '../../common/generate-mute-query';
 import { activeUsersChart } from '../../../../services/chart';
 import { generateRepliesQuery } from '../../common/generate-replies-query';
+import { injectPromo } from '../../common/inject-promo';
 
 export const meta = {
 	desc: {
@@ -169,6 +170,8 @@ export default define(meta, async (ps, user) => {
 
 	const timeline = await query.take(ps.limit!).getMany();
 
+	await injectPromo(user, timeline);
+
 	process.nextTick(() => {
 		if (user) {
 			activeUsersChart.update(user);
diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts
index 06f00969ac..68558fb84b 100644
--- a/src/server/api/endpoints/notes/local-timeline.ts
+++ b/src/server/api/endpoints/notes/local-timeline.ts
@@ -10,6 +10,7 @@ import { generateVisibilityQuery } from '../../common/generate-visibility-query'
 import { activeUsersChart } from '../../../../services/chart';
 import { Brackets } from 'typeorm';
 import { generateRepliesQuery } from '../../common/generate-replies-query';
+import { injectPromo } from '../../common/inject-promo';
 
 export const meta = {
 	desc: {
@@ -122,6 +123,8 @@ export default define(meta, async (ps, user) => {
 
 	const timeline = await query.take(ps.limit!).getMany();
 
+	await injectPromo(user, timeline);
+
 	process.nextTick(() => {
 		if (user) {
 			activeUsersChart.update(user);
diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts
index 2c25fbc968..8edf303e0d 100644
--- a/src/server/api/endpoints/notes/timeline.ts
+++ b/src/server/api/endpoints/notes/timeline.ts
@@ -8,6 +8,7 @@ import { generateMuteQuery } from '../../common/generate-mute-query';
 import { activeUsersChart } from '../../../../services/chart';
 import { Brackets } from 'typeorm';
 import { generateRepliesQuery } from '../../common/generate-replies-query';
+import { injectPromo } from '../../common/inject-promo';
 
 export const meta = {
 	desc: {
@@ -155,6 +156,8 @@ export default define(meta, async (ps, user) => {
 
 	const timeline = await query.take(ps.limit!).getMany();
 
+	await injectPromo(user, timeline);
+
 	process.nextTick(() => {
 		if (user) {
 			activeUsersChart.update(user);