From 9364f32bc26a4edb052e0a4b986329e8b076f5ed Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Mon, 27 May 2024 20:43:12 +0900
Subject: [PATCH 01/17] chore: drop scheduled_note table

---
 .../1716804636187-refactor-scheduled-posts.ts | 81 +++++++++++++++++++
 1 file changed, 81 insertions(+)
 create mode 100644 packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts

diff --git a/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts b/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts
new file mode 100644
index 0000000000..939120abec
--- /dev/null
+++ b/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts
@@ -0,0 +1,81 @@
+import type { MigrationInterface, QueryRunner } from "typeorm";
+
+export class RefactorScheduledPosts1716804636187 implements MigrationInterface {
+	public async up(queryRunner: QueryRunner): Promise<void> {
+		await queryRunner.query(
+			`ALTER TABLE "note" ADD COLUMN "scheduledAt" timestamp with time zone`,
+		);
+		await queryRunner.query(
+			`CREATE TEMP TABLE "tmp_scheduled_note" (LIKE "note")`,
+		);
+		await queryRunner.query(
+			`INSERT INTO "tmp_scheduled_note" (SELECT * FROM "note" WHERE "note"."id" IN (SELECT "noteId" FROM "scheduled_note"))`,
+		);
+		await queryRunner.query(
+			`UPDATE "tmp_scheduled_note" SET "scheduledAt" = "scheduled_note"."scheduledAt" FROM "scheduled_note" WHERE "tmp_scheduled_note"."id" = "scheduled_note"."noteId"`,
+		);
+		await queryRunner.query(
+			`DELETE FROM "note" WHERE "note"."id" IN (SELECT "noteId" FROM "scheduled_note")`,
+		);
+		await queryRunner.query(
+			`INSERT INTO "note" SELECT * FROM "tmp_scheduled_note"`,
+		);
+		await queryRunner.query(`DROP TABLE "scheduled_note"`);
+	}
+
+	public async down(queryRunner: QueryRunner): Promise<void> {
+		await queryRunner.query(
+			`CREATE TABLE "scheduled_note" (
+					"id" character varying(32) NOT NULL PRIMARY KEY,
+					"noteId" character varying(32) NOT NULL,
+					"userId" character varying(32) NOT NULL,
+					"scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL
+				)`,
+		);
+		await queryRunner.query(
+			`COMMENT ON COLUMN "scheduled_note"."noteId" IS 'The ID of the temporarily created note that corresponds to the schedule.'`,
+		);
+		// temp function to populate "scheduled_note"."id" with random values (as it's unused)
+		await queryRunner.query("CREATE EXTENSION pgcrypto");
+		await queryRunner.query(`
+			CREATE FUNCTION generate_scheduled_note_id(size int) RETURNS text AS $$	DECLARE
+				characters text := 'abcdefghijklmnopqrstuvwxyz0123456789';
+				bytes bytea := gen_random_bytes(size);
+				l int := length(characters);
+				i int := 0;
+				output text := '';
+				BEGIN
+					WHILE i < size LOOP
+						output := output || substr(characters, get_byte(bytes, i) % l + 1, 1);
+						i := i + 1;
+					END LOOP;
+					RETURN output;
+				END;
+			$$ LANGUAGE plpgsql VOLATILE;
+		`);
+		await queryRunner.query(
+			`INSERT INTO "scheduled_note" ("id", "noteId", "userId", "scheduledAt") (SELECT generate_scheduled_note_id(16), "id", "userId", "scheduledAt" FROM "note" WHERE "note"."scheduledAt" IS NOT NULL)`,
+		);
+		await queryRunner.query("DROP EXTENSION pgcrypto");
+		await queryRunner.query(`DROP FUNCTION "generate_scheduled_note_id"`);
+		await queryRunner.query(
+			`CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId")`
+		);
+		await queryRunner.query(
+			`CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId")`
+		);
+		await queryRunner.query(`
+				ALTER TABLE "scheduled_note"
+				ADD FOREIGN KEY ("noteId") REFERENCES "note"("id")
+				ON DELETE CASCADE
+				ON UPDATE NO ACTION
+		`);
+		await queryRunner.query(`
+				ALTER TABLE "scheduled_note"
+				ADD FOREIGN KEY ("userId") REFERENCES "user"("id")
+				ON DELETE CASCADE
+				ON UPDATE NO ACTION
+		`);
+		await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "scheduledAt"`);
+	}
+}

From 14834c187b8fd33bc698b43f5dd7d8cd72983384 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Mon, 27 May 2024 20:53:55 +0900
Subject: [PATCH 02/17] chore: update downgrade.sql

---
 docs/downgrade.sql | 33 +++++++++++++++++++++++++++++++++
 1 file changed, 33 insertions(+)

diff --git a/docs/downgrade.sql b/docs/downgrade.sql
index e7de398a22..895046087a 100644
--- a/docs/downgrade.sql
+++ b/docs/downgrade.sql
@@ -1,6 +1,7 @@
 BEGIN;
 
 DELETE FROM "migrations" WHERE name IN (
+    'RefactorScheduledPosts1716804636187',
     'RemoveEnumTypenameSuffix1716462794927',
     'CreateScheduledNote1714728200194',
     'AddBackTimezone1715351290096',
@@ -33,6 +34,38 @@ DELETE FROM "migrations" WHERE name IN (
     'RemoveNativeUtilsMigration1705877093218'
 );
 
+-- refactor-scheduled-post
+CREATE TABLE "scheduled_note" (
+	"id" character varying(32) NOT NULL PRIMARY KEY,
+	"noteId" character varying(32) NOT NULL,
+	"userId" character varying(32) NOT NULL,
+	"scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL
+);
+COMMENT ON COLUMN "scheduled_note"."noteId" IS 'The ID of the temporarily created note that corresponds to the schedule.';
+CREATE EXTENSION pgcrypto;
+CREATE FUNCTION generate_scheduled_note_id(size int) RETURNS text AS $$	DECLARE
+	characters text := 'abcdefghijklmnopqrstuvwxyz0123456789';
+	bytes bytea := gen_random_bytes(size);
+	l int := length(characters);
+	i int := 0;
+	output text := '';
+	BEGIN
+		WHILE i < size LOOP
+			output := output || substr(characters, get_byte(bytes, i) % l + 1, 1);
+			i := i + 1;
+		END LOOP;
+		RETURN output;
+	END;
+$$ LANGUAGE plpgsql VOLATILE;
+INSERT INTO "scheduled_note" ("id", "noteId", "userId", "scheduledAt") (SELECT generate_scheduled_note_id(16), "id", "userId", "scheduledAt" FROM "note" WHERE "note"."scheduledAt" IS NOT NULL);
+DROP EXTENSION pgcrypto;
+DROP FUNCTION "generate_scheduled_note_id";
+CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId");
+CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId");
+ALTER TABLE "scheduled_note" ADD FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
+ALTER TABLE "scheduled_note" ADD FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
+ALTER TABLE "note" DROP COLUMN "scheduledAt";
+
 -- remove-enum-typename-suffix
 ALTER TYPE "antenna_src" RENAME TO "antenna_src_enum";
 ALTER TYPE "drive_file_usage_hint" RENAME TO "drive_file_usage_hint_enum";

From abbbfa9a0aa837e9c781cc69969df7ef584b89ce Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Mon, 27 May 2024 21:01:42 +0900
Subject: [PATCH 03/17] chore: update auto-generated files

---
 packages/backend-rs/index.d.ts                |  7 +--
 packages/backend-rs/src/model/entity/mod.rs   |  1 -
 packages/backend-rs/src/model/entity/note.rs  | 10 +---
 .../backend-rs/src/model/entity/prelude.rs    |  1 -
 .../src/model/entity/scheduled_note.rs        | 55 -------------------
 packages/backend-rs/src/model/entity/user.rs  |  8 ---
 6 files changed, 3 insertions(+), 79 deletions(-)
 delete mode 100644 packages/backend-rs/src/model/entity/scheduled_note.rs

diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts
index 7be4b269e6..57e2ae8ed8 100644
--- a/packages/backend-rs/index.d.ts
+++ b/packages/backend-rs/index.d.ts
@@ -749,6 +749,7 @@ export interface Note {
   threadId: string | null
   updatedAt: DateTimeWithTimeZone | null
   lang: string | null
+  scheduledAt: DateTimeWithTimeZone | null
 }
 export interface NoteEdit {
   id: string
@@ -908,12 +909,6 @@ export interface ReplyMuting {
   muteeId: string
   muterId: string
 }
-export interface ScheduledNote {
-  id: string
-  noteId: string
-  userId: string
-  scheduledAt: DateTimeWithTimeZone
-}
 export enum AntennaSrc {
   All = 'all',
   Group = 'group',
diff --git a/packages/backend-rs/src/model/entity/mod.rs b/packages/backend-rs/src/model/entity/mod.rs
index 7f8d16f1ae..ffb21352d2 100644
--- a/packages/backend-rs/src/model/entity/mod.rs
+++ b/packages/backend-rs/src/model/entity/mod.rs
@@ -53,7 +53,6 @@ pub mod registry_item;
 pub mod relay;
 pub mod renote_muting;
 pub mod reply_muting;
-pub mod scheduled_note;
 pub mod sea_orm_active_enums;
 pub mod signin;
 pub mod sw_subscription;
diff --git a/packages/backend-rs/src/model/entity/note.rs b/packages/backend-rs/src/model/entity/note.rs
index 37cbd54862..4733e85d26 100644
--- a/packages/backend-rs/src/model/entity/note.rs
+++ b/packages/backend-rs/src/model/entity/note.rs
@@ -68,6 +68,8 @@ pub struct Model {
     #[sea_orm(column_name = "updatedAt")]
     pub updated_at: Option<DateTimeWithTimeZone>,
     pub lang: Option<String>,
+    #[sea_orm(column_name = "scheduledAt")]
+    pub scheduled_at: Option<DateTimeWithTimeZone>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -124,8 +126,6 @@ pub enum Relation {
     PromoNote,
     #[sea_orm(has_many = "super::promo_read::Entity")]
     PromoRead,
-    #[sea_orm(has_many = "super::scheduled_note::Entity")]
-    ScheduledNote,
     #[sea_orm(
         belongs_to = "super::user::Entity",
         from = "Column::UserId",
@@ -228,12 +228,6 @@ impl Related<super::promo_read::Entity> for Entity {
     }
 }
 
-impl Related<super::scheduled_note::Entity> for Entity {
-    fn to() -> RelationDef {
-        Relation::ScheduledNote.def()
-    }
-}
-
 impl Related<super::user::Entity> for Entity {
     fn to() -> RelationDef {
         Relation::User.def()
diff --git a/packages/backend-rs/src/model/entity/prelude.rs b/packages/backend-rs/src/model/entity/prelude.rs
index 9da0c02506..57fff023db 100644
--- a/packages/backend-rs/src/model/entity/prelude.rs
+++ b/packages/backend-rs/src/model/entity/prelude.rs
@@ -51,7 +51,6 @@ pub use super::registry_item::Entity as RegistryItem;
 pub use super::relay::Entity as Relay;
 pub use super::renote_muting::Entity as RenoteMuting;
 pub use super::reply_muting::Entity as ReplyMuting;
-pub use super::scheduled_note::Entity as ScheduledNote;
 pub use super::signin::Entity as Signin;
 pub use super::sw_subscription::Entity as SwSubscription;
 pub use super::used_username::Entity as UsedUsername;
diff --git a/packages/backend-rs/src/model/entity/scheduled_note.rs b/packages/backend-rs/src/model/entity/scheduled_note.rs
deleted file mode 100644
index f4c5b0b4c4..0000000000
--- a/packages/backend-rs/src/model/entity/scheduled_note.rs
+++ /dev/null
@@ -1,55 +0,0 @@
-//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
-
-use sea_orm::entity::prelude::*;
-
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, serde::Serialize, serde::Deserialize)]
-#[serde(rename_all = "camelCase")]
-#[sea_orm(table_name = "scheduled_note")]
-#[cfg_attr(
-    feature = "napi",
-    napi_derive::napi(object, js_name = "ScheduledNote", use_nullable = true)
-)]
-pub struct Model {
-    #[sea_orm(primary_key, auto_increment = false)]
-    pub id: String,
-    #[sea_orm(column_name = "noteId")]
-    pub note_id: String,
-    #[sea_orm(column_name = "userId")]
-    pub user_id: String,
-    #[sea_orm(column_name = "scheduledAt")]
-    pub scheduled_at: DateTimeWithTimeZone,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
-    #[sea_orm(
-        belongs_to = "super::note::Entity",
-        from = "Column::NoteId",
-        to = "super::note::Column::Id",
-        on_update = "NoAction",
-        on_delete = "Cascade"
-    )]
-    Note,
-    #[sea_orm(
-        belongs_to = "super::user::Entity",
-        from = "Column::UserId",
-        to = "super::user::Column::Id",
-        on_update = "NoAction",
-        on_delete = "Cascade"
-    )]
-    User,
-}
-
-impl Related<super::note::Entity> for Entity {
-    fn to() -> RelationDef {
-        Relation::Note.def()
-    }
-}
-
-impl Related<super::user::Entity> for Entity {
-    fn to() -> RelationDef {
-        Relation::User.def()
-    }
-}
-
-impl ActiveModelBehavior for ActiveModel {}
diff --git a/packages/backend-rs/src/model/entity/user.rs b/packages/backend-rs/src/model/entity/user.rs
index 974ba2890c..309410b0b6 100644
--- a/packages/backend-rs/src/model/entity/user.rs
+++ b/packages/backend-rs/src/model/entity/user.rs
@@ -153,8 +153,6 @@ pub enum Relation {
     PromoRead,
     #[sea_orm(has_many = "super::registry_item::Entity")]
     RegistryItem,
-    #[sea_orm(has_many = "super::scheduled_note::Entity")]
-    ScheduledNote,
     #[sea_orm(has_many = "super::signin::Entity")]
     Signin,
     #[sea_orm(has_many = "super::sw_subscription::Entity")]
@@ -347,12 +345,6 @@ impl Related<super::registry_item::Entity> for Entity {
     }
 }
 
-impl Related<super::scheduled_note::Entity> for Entity {
-    fn to() -> RelationDef {
-        Relation::ScheduledNote.def()
-    }
-}
-
 impl Related<super::signin::Entity> for Entity {
     fn to() -> RelationDef {
         Relation::Signin.def()

From a0f65cc6bc15b352f8bc0ed04216b3474f1a5e73 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Mon, 27 May 2024 21:44:39 +0900
Subject: [PATCH 04/17] refactor: remove scheduled note entity from backend

---
 packages/backend/src/db/postgre.ts            |   2 -
 .../1716804636187-refactor-scheduled-posts.ts |   4 +-
 packages/backend/src/models/entities/note.ts  |   5 +
 .../src/models/entities/scheduled-note.ts     |  48 ---------
 packages/backend/src/models/index.ts          |   2 -
 .../backend/src/models/repositories/note.ts   |  12 +--
 .../src/queue/processors/db/scheduled-note.ts |  36 +++----
 .../src/server/api/endpoints/notes/create.ts  |  16 +--
 packages/backend/src/services/note/create.ts  | 101 ++++++++----------
 9 files changed, 75 insertions(+), 151 deletions(-)
 delete mode 100644 packages/backend/src/models/entities/scheduled-note.ts

diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts
index 62bd2f9a1c..360ccfa38c 100644
--- a/packages/backend/src/db/postgre.ts
+++ b/packages/backend/src/db/postgre.ts
@@ -74,7 +74,6 @@ import { Webhook } from "@/models/entities/webhook.js";
 import { UserIp } from "@/models/entities/user-ip.js";
 import { NoteEdit } from "@/models/entities/note-edit.js";
 import { NoteFile } from "@/models/entities/note-file.js";
-import { ScheduledNote } from "@/models/entities/scheduled-note.js";
 
 import { entities as charts } from "@/services/chart/entities.js";
 import { dbLogger } from "./logger.js";
@@ -183,7 +182,6 @@ export const entities = [
 	UserPending,
 	Webhook,
 	UserIp,
-	ScheduledNote,
 	...charts,
 ];
 
diff --git a/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts b/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts
index 939120abec..7beaadace2 100644
--- a/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts
+++ b/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts
@@ -59,10 +59,10 @@ export class RefactorScheduledPosts1716804636187 implements MigrationInterface {
 		await queryRunner.query("DROP EXTENSION pgcrypto");
 		await queryRunner.query(`DROP FUNCTION "generate_scheduled_note_id"`);
 		await queryRunner.query(
-			`CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId")`
+			`CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId")`,
 		);
 		await queryRunner.query(
-			`CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId")`
+			`CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId")`,
 		);
 		await queryRunner.query(`
 				ALTER TABLE "scheduled_note"
diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts
index 1c9d3570a3..97db90a880 100644
--- a/packages/backend/src/models/entities/note.ts
+++ b/packages/backend/src/models/entities/note.ts
@@ -31,6 +31,11 @@ export class Note {
 	})
 	public createdAt: Date;
 
+	@Column("timestamp with time zone", {
+		nullable: true,
+	})
+	public scheduledAt: Date | null;
+
 	@Index()
 	@Column({
 		...id(),
diff --git a/packages/backend/src/models/entities/scheduled-note.ts b/packages/backend/src/models/entities/scheduled-note.ts
deleted file mode 100644
index 6c0b1296d8..0000000000
--- a/packages/backend/src/models/entities/scheduled-note.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import {
-	Entity,
-	JoinColumn,
-	Column,
-	ManyToOne,
-	OneToOne,
-	PrimaryColumn,
-	Index,
-	type Relation,
-} from "typeorm";
-import { Note } from "./note.js";
-import { id } from "../id.js";
-import { User } from "./user.js";
-
-@Entity()
-export class ScheduledNote {
-	@PrimaryColumn(id())
-	public id: string;
-
-	@Index()
-	@Column({
-		...id(),
-		comment:
-			"The ID of the temporarily created note that corresponds to the schedule.",
-	})
-	public noteId: Note["id"];
-
-	@Index()
-	@Column(id())
-	public userId: User["id"];
-
-	@Column("timestamp with time zone")
-	public scheduledAt: Date;
-
-	//#region Relations
-	@OneToOne(() => Note, {
-		onDelete: "CASCADE",
-	})
-	@JoinColumn()
-	public note: Relation<Note>;
-
-	@ManyToOne(() => User, {
-		onDelete: "CASCADE",
-	})
-	@JoinColumn()
-	public user: Relation<User>;
-	//#endregion
-}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index d6b81ad70e..c578d9d409 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -67,7 +67,6 @@ import { Webhook } from "./entities/webhook.js";
 import { UserIp } from "./entities/user-ip.js";
 import { NoteFileRepository } from "./repositories/note-file.js";
 import { NoteEditRepository } from "./repositories/note-edit.js";
-import { ScheduledNote } from "./entities/scheduled-note.js";
 
 export const Announcements = db.getRepository(Announcement);
 export const AnnouncementReads = db.getRepository(AnnouncementRead);
@@ -136,4 +135,3 @@ export const RegistryItems = db.getRepository(RegistryItem);
 export const Webhooks = db.getRepository(Webhook);
 export const Ads = db.getRepository(Ad);
 export const PasswordResetRequests = db.getRepository(PasswordResetRequest);
-export const ScheduledNotes = db.getRepository(ScheduledNote);
diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts
index 7e362faf88..e981c933d3 100644
--- a/packages/backend/src/models/repositories/note.ts
+++ b/packages/backend/src/models/repositories/note.ts
@@ -11,7 +11,6 @@ import {
 	Polls,
 	Channels,
 	Notes,
-	ScheduledNotes,
 } from "../index.js";
 import type { Packed } from "@/misc/schema.js";
 import { countReactions, decodeReaction, nyaify } from "backend-rs";
@@ -199,19 +198,11 @@ export const NoteRepository = db.getRepository(Note).extend({
 			host,
 		);
 
-		let scheduledAt: string | undefined;
-		if (note.visibility === "specified" && note.visibleUserIds.length === 0) {
-			scheduledAt = (
-				await ScheduledNotes.findOneBy({
-					noteId: note.id,
-				})
-			)?.scheduledAt?.toISOString();
-		}
-
 		const reactionEmoji = await populateEmojis(reactionEmojiNames, host);
 		const packed: Packed<"Note"> = await awaitAll({
 			id: note.id,
 			createdAt: note.createdAt.toISOString(),
+			scheduledAt: note.scheduledAt,
 			userId: note.userId,
 			user: Users.pack(note.user ?? note.userId, me, {
 				detail: false,
@@ -241,7 +232,6 @@ export const NoteRepository = db.getRepository(Note).extend({
 						},
 					})
 				: undefined,
-			scheduledAt,
 			reactions: countReactions(note.reactions),
 			reactionEmojis: reactionEmoji,
 			emojis: noteEmoji,
diff --git a/packages/backend/src/queue/processors/db/scheduled-note.ts b/packages/backend/src/queue/processors/db/scheduled-note.ts
index def37f1306..8dce61a129 100644
--- a/packages/backend/src/queue/processors/db/scheduled-note.ts
+++ b/packages/backend/src/queue/processors/db/scheduled-note.ts
@@ -1,4 +1,4 @@
-import { Users, Notes, ScheduledNotes, DriveFiles } from "@/models/index.js";
+import { Users, Notes, DriveFiles } from "@/models/index.js";
 import type { DbUserScheduledNoteData } from "@/queue/types.js";
 import { queueLogger } from "../../logger.js";
 import type Bull from "bull";
@@ -20,46 +20,46 @@ export async function scheduledNote(
 		return;
 	}
 
-	const note = await Notes.findOneBy({ id: job.data.noteId });
-	if (note == null) {
+	const draftNote = await Notes.findOneBy({ id: job.data.noteId });
+	if (draftNote == null) {
+		logger.warn(`Note ${job.data.noteId} does not exist`);
 		done();
 		return;
 	}
-	const files = await DriveFiles.findBy({ id: In(note.fileIds) });
+	const files = await DriveFiles.findBy({ id: In(draftNote.fileIds) });
 
 	if (user.isSuspended) {
-		deleteNote(user, note);
+		logger.info(`Cancelled due to user ${job.data.user.id} being suspended`);
+		deleteNote(user, draftNote);
 		done();
 		return;
 	}
 
-	await ScheduledNotes.delete({
-		noteId: note.id,
-		userId: user.id,
-	});
-
 	const visibleUsers = job.data.option.visibleUserIds
 		? await Users.findBy({
 				id: In(job.data.option.visibleUserIds),
 			})
 		: [];
 
+	// Create scheduled (actual) note
 	await createNote(user, {
 		createdAt: new Date(),
+		scheduledAt: null,
 		files,
 		poll: job.data.option.poll,
-		text: note.text || undefined,
-		lang: note.lang,
-		reply: note.reply,
-		renote: note.renote,
-		cw: note.cw,
-		localOnly: note.localOnly,
+		text: draftNote.text || undefined,
+		lang: draftNote.lang,
+		reply: draftNote.reply,
+		renote: draftNote.renote,
+		cw: draftNote.cw,
+		localOnly: draftNote.localOnly,
 		visibility: job.data.option.visibility,
 		visibleUsers,
-		channel: note.channel,
+		channel: draftNote.channel,
 	});
 
-	await deleteNote(user, note);
+	// Delete temporal (draft) note
+	await deleteNote(user, draftNote);
 
 	logger.info("Success");
 
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 50179db5e2..fee5d0fabc 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -7,7 +7,6 @@ import {
 	Notes,
 	Channels,
 	Blockings,
-	ScheduledNotes,
 } from "@/models/index.js";
 import type { DriveFile } from "@/models/entities/drive-file.js";
 import type { Note } from "@/models/entities/note.js";
@@ -16,7 +15,7 @@ import { config } from "@/config.js";
 import { noteVisibilities } from "@/types.js";
 import { ApiError } from "@/server/api/error.js";
 import define from "@/server/api/define.js";
-import { HOUR, genId } from "backend-rs";
+import { HOUR } from "backend-rs";
 import { getNote } from "@/server/api/common/getters.js";
 import { langmap } from "firefish-js";
 import { createScheduledNoteJob } from "@/queue/index.js";
@@ -303,7 +302,7 @@ export default define(meta, paramDef, async (ps, user) => {
 	}
 
 	let delay: number | null = null;
-	if (ps.scheduledAt) {
+	if (ps.scheduledAt != null) {
 		delay = ps.scheduledAt - Date.now();
 		if (delay < 0) {
 			delay = null;
@@ -315,12 +314,14 @@ export default define(meta, paramDef, async (ps, user) => {
 		user,
 		{
 			createdAt: new Date(),
+			scheduledAt: delay != null ? new Date(ps.scheduledAt!) : null,
 			files: files,
 			poll: ps.poll
 				? {
 						choices: ps.poll.choices,
 						multiple: ps.poll.multiple,
-						expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
+						expiresAt:
+							ps.poll.expiresAt != null ? new Date(ps.poll.expiresAt) : null,
 					}
 				: undefined,
 			text: ps.text || undefined,
@@ -346,13 +347,6 @@ export default define(meta, paramDef, async (ps, user) => {
 		false,
 		delay
 			? async (note) => {
-					await ScheduledNotes.insert({
-						id: genId(),
-						noteId: note.id,
-						userId: user.id,
-						scheduledAt: new Date(ps.scheduledAt as number),
-					});
-
 					createScheduledNoteJob(
 						{
 							user: { id: user.id },
diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index 8fe44a60ba..dc9d2a7fc6 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -133,15 +133,10 @@ class NotificationManager {
 	}
 }
 
-type MinimumUser = {
-	id: User["id"];
-	host: User["host"];
-	username: User["username"];
-	uri: User["uri"];
-};
-
-type Option = {
+type UserLike = Pick<User, "id" | "host" | "username" | "uri">;
+type NoteLike = {
 	createdAt?: Date | null;
+	scheduledAt?: Date | null;
 	name?: string | null;
 	text?: string | null;
 	lang?: string | null;
@@ -152,9 +147,9 @@ type Option = {
 	localOnly?: boolean | null;
 	cw?: string | null;
 	visibility?: string;
-	visibleUsers?: MinimumUser[] | null;
+	visibleUsers?: UserLike[] | null;
 	channel?: Channel | null;
-	apMentions?: MinimumUser[] | null;
+	apMentions?: UserLike[] | null;
 	apHashtags?: string[] | null;
 	apEmojis?: string[] | null;
 	uri?: string | null;
@@ -163,20 +158,15 @@ type Option = {
 };
 
 export default async (
-	user: {
-		id: User["id"];
-		username: User["username"];
-		host: User["host"];
-		isSilenced: User["isSilenced"];
-		createdAt: User["createdAt"];
-		isBot: User["isBot"];
-		inbox?: User["inbox"];
-	},
-	data: Option,
+	user: Pick<
+		User,
+		"id" | "username" | "host" | "isSilenced" | "createdAt" | "isBot"
+	> & { inbox?: User["inbox"] },
+	data: NoteLike,
 	silent = false,
 	waitToPublish?: (note: Note) => Promise<void>,
 ) =>
-	// biome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME
+	// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
 	new Promise<Note>(async (res, rej) => {
 		const dontFederateInitially =
 			data.visibility?.startsWith("hidden") === true;
@@ -208,6 +198,7 @@ export default async (
 			data.createdAt > now
 		)
 			data.createdAt = now;
+
 		if (data.visibility == null) data.visibility = "public";
 		if (data.localOnly == null) data.localOnly = false;
 		if (data.channel != null) data.visibility = "public";
@@ -277,11 +268,7 @@ export default async (
 			data.localOnly = true;
 		}
 
-		if (data.text) {
-			data.text = data.text.trim();
-		} else {
-			data.text = null;
-		}
+		data.text = data.text?.trim() ?? null;
 
 		if (data.lang) {
 			if (!Object.keys(langmap).includes(data.lang.toLowerCase()))
@@ -297,10 +284,10 @@ export default async (
 
 		// Parse MFM if needed
 		if (!(tags && emojis && mentionedUsers)) {
-			const tokens = data.text ? mfm.parse(data.text)! : [];
-			const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
+			const tokens = data.text ? mfm.parse(data.text) : [];
+			const cwTokens = data.cw ? mfm.parse(data.cw) : [];
 			const choiceTokens = data.poll?.choices
-				? concat(data.poll.choices.map((choice) => mfm.parse(choice)!))
+				? concat(data.poll.choices.map((choice) => mfm.parse(choice)))
 				: [];
 
 			const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
@@ -318,12 +305,12 @@ export default async (
 			.splice(0, 32);
 
 		if (
-			data.reply &&
+			data.reply != null &&
 			user.id !== data.reply.userId &&
-			!mentionedUsers.some((u) => u.id === data.reply!.userId)
+			!mentionedUsers.some((u) => u.id === data.reply?.userId)
 		) {
 			mentionedUsers.push(
-				await Users.findOneByOrFail({ id: data.reply!.userId }),
+				await Users.findOneByOrFail({ id: data.reply.userId }),
 			);
 		}
 
@@ -338,10 +325,10 @@ export default async (
 
 			if (
 				data.reply &&
-				!data.visibleUsers.some((x) => x.id === data.reply!.userId)
+				!data.visibleUsers.some((x) => x.id === data.reply?.userId)
 			) {
 				data.visibleUsers.push(
-					await Users.findOneByOrFail({ id: data.reply!.userId }),
+					await Users.findOneByOrFail({ id: data.reply?.userId }),
 				);
 			}
 		}
@@ -561,7 +548,7 @@ export default async (
 						publishMainStream(data.reply.userId, "reply", packedReply);
 
 						const webhooks = (await getActiveWebhooks()).filter(
-							(x) => x.userId === data.reply!.userId && x.on.includes("reply"),
+							(x) => x.userId === data.reply?.userId && x.on.includes("reply"),
 						);
 						for (const webhook of webhooks) {
 							webhookDeliver(webhook, "reply", {
@@ -672,7 +659,7 @@ export default async (
 		}
 	});
 
-async function renderNoteOrRenoteActivity(data: Option, note: Note) {
+async function renderNoteOrRenoteActivity(data: NoteLike, note: Note) {
 	if (data.localOnly) return null;
 
 	const content =
@@ -704,17 +691,17 @@ function incRenoteCount(renote: Note) {
 
 async function insertNote(
 	user: { id: User["id"]; host: User["host"] },
-	data: Option,
+	data: NoteLike,
 	tags: string[],
 	emojis: string[],
-	mentionedUsers: MinimumUser[],
+	mentionedUsers: UserLike[],
 ) {
-	if (data.createdAt === null || data.createdAt === undefined) {
-		data.createdAt = new Date();
-	}
-	const insert = new Note({
+	data.createdAt ??= new Date();
+
+	const note = new Note({
 		id: genIdAt(data.createdAt),
 		createdAt: data.createdAt,
+		scheduledAt: data.scheduledAt ?? null,
 		fileIds: data.files ? data.files.map((file) => file.id) : [],
 		replyId: data.reply ? data.reply.id : null,
 		renoteId: data.renote ? data.renote.id : null,
@@ -743,7 +730,7 @@ async function insertNote(
 
 		attachedFileTypes: data.files ? data.files.map((file) => file.type) : [],
 
-		// 以下非正規化データ
+		// denormalized fields
 		replyUserId: data.reply ? data.reply.userId : null,
 		replyUserHost: data.reply ? data.reply.userHost : null,
 		renoteUserId: data.renote ? data.renote.userId : null,
@@ -751,22 +738,22 @@ async function insertNote(
 		userHost: user.host,
 	});
 
-	if (data.uri != null) insert.uri = data.uri;
-	if (data.url != null) insert.url = data.url;
+	if (data.uri != null) note.uri = data.uri;
+	if (data.url != null) note.url = data.url;
 
 	// Append mentions data
 	if (mentionedUsers.length > 0) {
-		insert.mentions = mentionedUsers.map((u) => u.id);
-		const profiles = await UserProfiles.findBy({ userId: In(insert.mentions) });
-		insert.mentionedRemoteUsers = JSON.stringify(
+		note.mentions = mentionedUsers.map((u) => u.id);
+		const profiles = await UserProfiles.findBy({ userId: In(note.mentions) });
+		note.mentionedRemoteUsers = JSON.stringify(
 			mentionedUsers
 				.filter((u) => Users.isRemoteUser(u))
 				.map((u) => {
 					const profile = profiles.find((p) => p.userId === u.id);
-					const url = profile != null ? profile.url : null;
+					const url = profile?.url ?? null;
 					return {
 						uri: u.uri,
-						url: url == null ? undefined : url,
+						url: url ?? undefined,
 						username: u.username,
 						host: u.host,
 					} as IMentionedRemoteUsers[0];
@@ -776,12 +763,12 @@ async function insertNote(
 
 	// 投稿を作成
 	try {
-		if (insert.hasPoll) {
+		if (note.hasPoll) {
 			// Start transaction
 			await db.transaction(async (transactionalEntityManager) => {
 				if (!data.poll) throw new Error("Empty poll data");
 
-				await transactionalEntityManager.insert(Note, insert);
+				await transactionalEntityManager.insert(Note, note);
 
 				let expiresAt: Date | null;
 				if (
@@ -794,12 +781,12 @@ async function insertNote(
 				}
 
 				const poll = new Poll({
-					noteId: insert.id,
+					noteId: note.id,
 					choices: data.poll.choices,
 					expiresAt,
 					multiple: data.poll.multiple,
 					votes: new Array(data.poll.choices.length).fill(0),
-					noteVisibility: insert.visibility,
+					noteVisibility: note.visibility,
 					userId: user.id,
 					userHost: user.host,
 				});
@@ -807,10 +794,10 @@ async function insertNote(
 				await transactionalEntityManager.insert(Poll, poll);
 			});
 		} else {
-			await Notes.insert(insert);
+			await Notes.insert(note);
 		}
 
-		return insert;
+		return note;
 	} catch (e) {
 		// duplicate key error
 		if (isDuplicateKeyValueError(e)) {
@@ -857,7 +844,7 @@ async function notifyToWatchersOfReplyee(
 }
 
 async function createMentionedEvents(
-	mentionedUsers: MinimumUser[],
+	mentionedUsers: UserLike[],
 	note: Note,
 	nm: NotificationManager,
 ) {

From 465d3b2272becd1b2ca07c88e8fc97bf6fd0f860 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Mon, 27 May 2024 22:15:51 +0900
Subject: [PATCH 05/17] why did this happen

---
 packages/backend/src/services/note/create.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index dc9d2a7fc6..5eb1ace5e2 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -166,7 +166,7 @@ export default async (
 	silent = false,
 	waitToPublish?: (note: Note) => Promise<void>,
 ) =>
-	// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
+	// biome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME
 	new Promise<Note>(async (res, rej) => {
 		const dontFederateInitially =
 			data.visibility?.startsWith("hidden") === true;

From 1e3e44902ea9b44de2cf96b502d4a601c1f5695b Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Mon, 27 May 2024 22:25:44 +0900
Subject: [PATCH 06/17] fix: throw an error if scheduled date is past

---
 packages/backend/src/server/api/endpoints/notes/create.ts | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index fee5d0fabc..7de4e3194c 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -94,6 +94,12 @@ export const meta = {
 			code: "ACCOUNT_LOCKED",
 			id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3",
 		},
+
+		scheduledTimeIsPast: {
+			message: "The scheduled time is past.",
+			code: "SCHEDULED_TIME_IS_PAST",
+			id: "277f91df-8d8e-4647-b4e3-5885fda8978a",
+		},
 	},
 } as const;
 
@@ -305,7 +311,7 @@ export default define(meta, paramDef, async (ps, user) => {
 	if (ps.scheduledAt != null) {
 		delay = ps.scheduledAt - Date.now();
 		if (delay < 0) {
-			delay = null;
+			throw new ApiError(meta.errors.scheduledTimeIsPast);
 		}
 	}
 

From 02101e04dcc2c48c2e949e6615cdaf18a06b596e Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Thu, 6 Jun 2024 10:05:58 +0900
Subject: [PATCH 07/17] fix (backend): scheduled reply/renote

---
 .../src/queue/processors/db/scheduled-note.ts | 29 ++++++++++++++++---
 packages/backend/src/queue/types.ts           |  2 ++
 .../src/server/api/endpoints/notes/create.ts  |  2 ++
 3 files changed, 29 insertions(+), 4 deletions(-)

diff --git a/packages/backend/src/queue/processors/db/scheduled-note.ts b/packages/backend/src/queue/processors/db/scheduled-note.ts
index 8dce61a129..415436677d 100644
--- a/packages/backend/src/queue/processors/db/scheduled-note.ts
+++ b/packages/backend/src/queue/processors/db/scheduled-note.ts
@@ -16,20 +16,23 @@ export async function scheduledNote(
 
 	const user = await Users.findOneBy({ id: job.data.user.id });
 	if (user == null) {
+		logger.warn(`User ${job.data.user.id} does not exist, aborting`);
 		done();
 		return;
 	}
 
 	const draftNote = await Notes.findOneBy({ id: job.data.noteId });
 	if (draftNote == null) {
-		logger.warn(`Note ${job.data.noteId} does not exist`);
+		logger.warn(`Note ${job.data.noteId} does not exist, aborting`);
 		done();
 		return;
 	}
 	const files = await DriveFiles.findBy({ id: In(draftNote.fileIds) });
 
 	if (user.isSuspended) {
-		logger.info(`Cancelled due to user ${job.data.user.id} being suspended`);
+		logger.info(
+			`Cancelled due to user ${job.data.user.id} being suspended, aborting`,
+		);
 		deleteNote(user, draftNote);
 		done();
 		return;
@@ -41,6 +44,24 @@ export async function scheduledNote(
 			})
 		: [];
 
+	const reply = await Notes.findOneBy({ id: job.data.option.replyId });
+	if (reply == null) {
+		logger.warn(
+			`Note ${job.data.option.replyId} (reply) does not exist, aborting`,
+		);
+		done();
+		return;
+	}
+
+	const renote = await Notes.findOneBy({ id: job.data.option.renoteId });
+	if (renote == null) {
+		logger.warn(
+			`Note ${job.data.option.replyId} (renote) does not exist, aborting`,
+		);
+		done();
+		return;
+	}
+
 	// Create scheduled (actual) note
 	await createNote(user, {
 		createdAt: new Date(),
@@ -49,8 +70,8 @@ export async function scheduledNote(
 		poll: job.data.option.poll,
 		text: draftNote.text || undefined,
 		lang: draftNote.lang,
-		reply: draftNote.reply,
-		renote: draftNote.renote,
+		reply,
+		renote,
 		cw: draftNote.cw,
 		localOnly: draftNote.localOnly,
 		visibility: job.data.option.visibility,
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index c0d719a312..2db59a031d 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -62,6 +62,8 @@ export type DbUserScheduledNoteData = {
 	option: {
 		visibility: string;
 		visibleUserIds?: string[] | null;
+		replyId?: string;
+		renoteId?: string;
 		poll?: IPoll;
 	};
 	noteId: Note["id"];
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 82bbc53352..17f9a7ece9 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -365,6 +365,8 @@ export default define(meta, paramDef, async (ps, user) => {
 									: undefined,
 								visibility: ps.visibility,
 								visibleUserIds: ps.visibleUserIds,
+								replyId: ps.replyId ?? undefined,
+								renoteId: ps.renoteId ?? undefined,
 							},
 						},
 						delay,

From 195701d29743eabc8f0cab1466e71951489033e1 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Thu, 6 Jun 2024 10:08:34 +0900
Subject: [PATCH 08/17] fix (backend): fix condition

---
 .../src/queue/processors/db/scheduled-note.ts    | 16 +++++++++++-----
 1 file changed, 11 insertions(+), 5 deletions(-)

diff --git a/packages/backend/src/queue/processors/db/scheduled-note.ts b/packages/backend/src/queue/processors/db/scheduled-note.ts
index 415436677d..a74d7a3866 100644
--- a/packages/backend/src/queue/processors/db/scheduled-note.ts
+++ b/packages/backend/src/queue/processors/db/scheduled-note.ts
@@ -44,8 +44,11 @@ export async function scheduledNote(
 			})
 		: [];
 
-	const reply = await Notes.findOneBy({ id: job.data.option.replyId });
-	if (reply == null) {
+	const reply =
+		job.data.option.replyId != null
+			? await Notes.findOneBy({ id: job.data.option.replyId })
+			: undefined;
+	if (job.data.option.replyId != null && reply == null) {
 		logger.warn(
 			`Note ${job.data.option.replyId} (reply) does not exist, aborting`,
 		);
@@ -53,10 +56,13 @@ export async function scheduledNote(
 		return;
 	}
 
-	const renote = await Notes.findOneBy({ id: job.data.option.renoteId });
-	if (renote == null) {
+	const renote =
+		job.data.option.renoteId != null
+			? await Notes.findOneBy({ id: job.data.option.renoteId })
+			: undefined;
+	if (job.data.option.renoteId != null && renote == null) {
 		logger.warn(
-			`Note ${job.data.option.replyId} (renote) does not exist, aborting`,
+			`Note ${job.data.option.renoteId} (renote) does not exist, aborting`,
 		);
 		done();
 		return;

From a9bec190de6fb50789fc932019be3dd74d69ecee Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Thu, 6 Jun 2024 10:15:25 +0900
Subject: [PATCH 09/17] chore (backend): promise handling

---
 .../src/queue/processors/db/scheduled-note.ts | 37 ++++++++++---------
 1 file changed, 20 insertions(+), 17 deletions(-)

diff --git a/packages/backend/src/queue/processors/db/scheduled-note.ts b/packages/backend/src/queue/processors/db/scheduled-note.ts
index a74d7a3866..3d3a711dfd 100644
--- a/packages/backend/src/queue/processors/db/scheduled-note.ts
+++ b/packages/backend/src/queue/processors/db/scheduled-note.ts
@@ -14,40 +14,47 @@ export async function scheduledNote(
 ): Promise<void> {
 	logger.info(`Creating: ${job.data.noteId}`);
 
-	const user = await Users.findOneBy({ id: job.data.user.id });
+	const [user, draftNote] = await Promise.all([
+		Users.findOneBy({ id: job.data.user.id }),
+		Notes.findOneBy({ id: job.data.noteId }),
+	]);
+
 	if (user == null) {
 		logger.warn(`User ${job.data.user.id} does not exist, aborting`);
 		done();
 		return;
 	}
 
-	const draftNote = await Notes.findOneBy({ id: job.data.noteId });
 	if (draftNote == null) {
 		logger.warn(`Note ${job.data.noteId} does not exist, aborting`);
 		done();
 		return;
 	}
-	const files = await DriveFiles.findBy({ id: In(draftNote.fileIds) });
 
 	if (user.isSuspended) {
 		logger.info(
 			`Cancelled due to user ${job.data.user.id} being suspended, aborting`,
 		);
-		deleteNote(user, draftNote);
+		await deleteNote(user, draftNote);
 		done();
 		return;
 	}
 
-	const visibleUsers = job.data.option.visibleUserIds
-		? await Users.findBy({
-				id: In(job.data.option.visibleUserIds),
-			})
-		: [];
-
-	const reply =
+	const [visibleUsers, reply, renote, files] = await Promise.all([
+		job.data.option.visibleUserIds
+			? Users.findBy({
+					id: In(job.data.option.visibleUserIds),
+				})
+			: [],
 		job.data.option.replyId != null
-			? await Notes.findOneBy({ id: job.data.option.replyId })
-			: undefined;
+			? Notes.findOneBy({ id: job.data.option.replyId })
+			: undefined,
+		job.data.option.renoteId != null
+			? Notes.findOneBy({ id: job.data.option.renoteId })
+			: undefined,
+		DriveFiles.findBy({ id: In(draftNote.fileIds) }),
+	]);
+
 	if (job.data.option.replyId != null && reply == null) {
 		logger.warn(
 			`Note ${job.data.option.replyId} (reply) does not exist, aborting`,
@@ -56,10 +63,6 @@ export async function scheduledNote(
 		return;
 	}
 
-	const renote =
-		job.data.option.renoteId != null
-			? await Notes.findOneBy({ id: job.data.option.renoteId })
-			: undefined;
 	if (job.data.option.renoteId != null && renote == null) {
 		logger.warn(
 			`Note ${job.data.option.renoteId} (renote) does not exist, aborting`,

From fd563fc5f68ed33007e73db751130fc3de7a339b Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Thu, 6 Jun 2024 10:34:53 +0900
Subject: [PATCH 10/17] hotfix for type error

---
 packages/backend/src/models/repositories/note.ts | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts
index e981c933d3..062973a741 100644
--- a/packages/backend/src/models/repositories/note.ts
+++ b/packages/backend/src/models/repositories/note.ts
@@ -202,7 +202,13 @@ export const NoteRepository = db.getRepository(Note).extend({
 		const packed: Packed<"Note"> = await awaitAll({
 			id: note.id,
 			createdAt: note.createdAt.toISOString(),
-			scheduledAt: note.scheduledAt,
+			// FIXME: note.scheduledAt should be a `Date`
+			scheduledAt:
+				note.scheduledAt == null
+					? undefined
+					: typeof note.scheduledAt === "string"
+						? note.scheduledAt
+						: note.scheduledAt?.toISOString(),
 			userId: note.userId,
 			user: Users.pack(note.user ?? note.userId, me, {
 				detail: false,

From ff49b8074126cacab30039909c9bc8adb8b96475 Mon Sep 17 00:00:00 2001
From: CI <project_7_bot_1bfaee5701aed20091a86249a967a6c1@noreply.firefish.dev>
Date: Thu, 6 Jun 2024 12:05:41 +0000
Subject: [PATCH 11/17] fix(deps): update bull-board to v5.20.1

---
 packages/backend/package.json |  6 ++---
 pnpm-lock.yaml                | 44 +++++++++++++++++++----------------
 2 files changed, 27 insertions(+), 23 deletions(-)

diff --git a/packages/backend/package.json b/packages/backend/package.json
index 1e3c72576f..1ae5453de3 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -22,9 +22,9 @@
 		"@swc/core-android-arm64": "1.3.11"
 	},
 	"dependencies": {
-		"@bull-board/api": "5.20.0",
-		"@bull-board/koa": "5.20.0",
-		"@bull-board/ui": "5.20.0",
+		"@bull-board/api": "5.20.1",
+		"@bull-board/koa": "5.20.1",
+		"@bull-board/ui": "5.20.1",
 		"@discordapp/twemoji": "15.0.3",
 		"@koa/cors": "5.0.0",
 		"@koa/multer": "3.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 96ed709aff..ff5e969bf0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -43,14 +43,14 @@ importers:
   packages/backend:
     dependencies:
       '@bull-board/api':
-        specifier: 5.20.0
-        version: 5.20.0(@bull-board/ui@5.20.0)
+        specifier: 5.20.1
+        version: 5.20.1(@bull-board/ui@5.20.1)
       '@bull-board/koa':
-        specifier: 5.20.0
-        version: 5.20.0(@types/koa@2.15.0)(lodash@4.17.21)(pug@3.0.3)
+        specifier: 5.20.1
+        version: 5.20.1(@types/koa@2.15.0)(lodash@4.17.21)(pug@3.0.3)
       '@bull-board/ui':
-        specifier: 5.20.0
-        version: 5.20.0
+        specifier: 5.20.1
+        version: 5.20.1
       '@discordapp/twemoji':
         specifier: 15.0.3
         version: 15.0.3
@@ -1146,11 +1146,13 @@ packages:
   '@biomejs/cli-darwin-arm64@1.8.0':
     resolution: {integrity: sha512-dBAYzfIJ1JmWigKlWourT3sJ3I60LZPjqNwwlsyFjiv5AV7vPeWlHVVIImV2BpINwNjZQhpXnwDfVnGS4vr7AA==}
     engines: {node: '>=14.21.3'}
+    cpu: [arm64]
     os: [darwin]
 
   '@biomejs/cli-darwin-x64@1.8.0':
     resolution: {integrity: sha512-ZTTSD0bP0nn9UpRDGQrQNTILcYSj+IkxTYr3CAV64DWBDtQBomlk2oVKWzDaA1LOhpAsTh0giLCbPJaVk2jfMQ==}
     engines: {node: '>=14.21.3'}
+    cpu: [x64]
     os: [darwin]
 
   '@biomejs/cli-linux-arm64-musl@1.8.0':
@@ -1162,6 +1164,7 @@ packages:
   '@biomejs/cli-linux-arm64@1.8.0':
     resolution: {integrity: sha512-cx725jTlJS6dskvJJwwCQaaMRBKE2Qss7ukzmx27Rn/DXRxz6tnnBix4FUGPf1uZfwrERkiJlbWM05JWzpvvXg==}
     engines: {node: '>=14.21.3'}
+    cpu: [arm64]
     os: [linux]
 
   '@biomejs/cli-linux-x64-musl@1.8.0':
@@ -1173,6 +1176,7 @@ packages:
   '@biomejs/cli-linux-x64@1.8.0':
     resolution: {integrity: sha512-cmgmhlD4QUxMhL1VdaNqnB81xBHb3R7huVNyYnPYzP+AykZ7XqJbPd1KcWAszNjUk2AHdx0aLKEBwCOWemxb2g==}
     engines: {node: '>=14.21.3'}
+    cpu: [x64]
     os: [linux]
 
   '@biomejs/cli-win32-arm64@1.8.0':
@@ -1187,16 +1191,16 @@ packages:
     cpu: [x64]
     os: [win32]
 
-  '@bull-board/api@5.20.0':
-    resolution: {integrity: sha512-WUSuHdunODSWX8rkZmTnXpJsrUHqLS6+H3IGN0MNh6ylM/plZxGJJ3jW4nTXePWASeLNoDC0gjRcziDjlodPnQ==}
+  '@bull-board/api@5.20.1':
+    resolution: {integrity: sha512-45aDhnOzWRrtUUAKHxdClSnLIus5f8BK3ATzb2IwI/BRgOi1lWTe1YG266hVqDdWXsUDxKzf75DAANKfAoEsRA==}
     peerDependencies:
-      '@bull-board/ui': 5.20.0
+      '@bull-board/ui': 5.20.1
 
-  '@bull-board/koa@5.20.0':
-    resolution: {integrity: sha512-jto1WNIBZscCSJMkGm4G4qrb5KhblyJT/Q/59k5hHg9fdogzW9PfR+UQj7OOjLTMNX3phtPNvL0jG7/3HfmtIA==}
+  '@bull-board/koa@5.20.1':
+    resolution: {integrity: sha512-xUEEpMsdzZWHZQMnfk4a9kFhCz4PQkMBnb/t/C2xYwG15nCLapHWqMd5xO59OxnWOZ5XIiLsJwtCuf0l5G+L8A==}
 
-  '@bull-board/ui@5.20.0':
-    resolution: {integrity: sha512-EuijEHzQ9EAaA8WD4B+iXpTWTvCpTU2pskVUOs8CFkp0AYBqIhQDOi1W3f+e3FMqwS81l2/WkVHKkd6QRzbRzA==}
+  '@bull-board/ui@5.20.1':
+    resolution: {integrity: sha512-RzNinC4FKHNuxzkIRsCL+n9iO5RxmF5YM7byCuuv1/UeFjtCtsLHFi6TI9ZgJsXETA2Uxq9Mg7ppncojUjrINw==}
 
   '@cbor-extract/cbor-extract-darwin-arm64@2.2.0':
     resolution: {integrity: sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==}
@@ -8306,15 +8310,15 @@ snapshots:
   '@biomejs/cli-win32-x64@1.8.0':
     optional: true
 
-  '@bull-board/api@5.20.0(@bull-board/ui@5.20.0)':
+  '@bull-board/api@5.20.1(@bull-board/ui@5.20.1)':
     dependencies:
-      '@bull-board/ui': 5.20.0
+      '@bull-board/ui': 5.20.1
       redis-info: 3.1.0
 
-  '@bull-board/koa@5.20.0(@types/koa@2.15.0)(lodash@4.17.21)(pug@3.0.3)':
+  '@bull-board/koa@5.20.1(@types/koa@2.15.0)(lodash@4.17.21)(pug@3.0.3)':
     dependencies:
-      '@bull-board/api': 5.20.0(@bull-board/ui@5.20.0)
-      '@bull-board/ui': 5.20.0
+      '@bull-board/api': 5.20.1(@bull-board/ui@5.20.1)
+      '@bull-board/ui': 5.20.1
       ejs: 3.1.10
       koa: 2.15.3
       koa-mount: 4.0.0
@@ -8377,9 +8381,9 @@ snapshots:
       - walrus
       - whiskers
 
-  '@bull-board/ui@5.20.0':
+  '@bull-board/ui@5.20.1':
     dependencies:
-      '@bull-board/api': 5.20.0(@bull-board/ui@5.20.0)
+      '@bull-board/api': 5.20.1(@bull-board/ui@5.20.1)
 
   '@cbor-extract/cbor-extract-darwin-arm64@2.2.0':
     optional: true

From e288a619821da7413a921f15a10145c7ff0e8f90 Mon Sep 17 00:00:00 2001
From: CI <project_7_bot_1bfaee5701aed20091a86249a967a6c1@noreply.firefish.dev>
Date: Thu, 6 Jun 2024 20:05:03 +0000
Subject: [PATCH 12/17] fix(deps): update dependency got to v14.4.1

---
 packages/backend/package.json |  2 +-
 pnpm-lock.yaml                | 11 ++++++-----
 2 files changed, 7 insertions(+), 6 deletions(-)

diff --git a/packages/backend/package.json b/packages/backend/package.json
index 1ae5453de3..b270265b2c 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -57,7 +57,7 @@
 		"firefish-js": "workspace:*",
 		"fluent-ffmpeg": "2.1.3",
 		"form-data": "4.0.0",
-		"got": "14.4.0",
+		"got": "14.4.1",
 		"gunzip-maybe": "1.4.2",
 		"hpagent": "1.2.0",
 		"ioredis": "5.4.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ff5e969bf0..9c3daf53e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -148,8 +148,8 @@ importers:
         specifier: 4.0.0
         version: 4.0.0
       got:
-        specifier: 14.4.0
-        version: 14.4.0
+        specifier: 14.4.1
+        version: 14.4.1
       gunzip-maybe:
         specifier: 1.4.2
         version: 1.4.2
@@ -4800,8 +4800,8 @@ packages:
     resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==}
     engines: {node: '>=10.19.0'}
 
-  got@14.4.0:
-    resolution: {integrity: sha512-baa2HMfREJ9UQSXOPwWe0DNK+FT8Okcxe9kmTJvaetv2q/MUxq0qFzEnfSbxo+wj45/QioGcH5ZhuT9VBIPJ5Q==}
+  got@14.4.1:
+    resolution: {integrity: sha512-IvDJbJBUeexX74xNQuMIVgCRRuNOm5wuK+OC3Dc2pnSoh1AOmgc7JVj7WC+cJ4u0aPcO9KZ2frTXcqK4W/5qTQ==}
     engines: {node: '>=20'}
 
   graceful-fs@4.2.11:
@@ -12518,7 +12518,7 @@ snapshots:
       p-cancelable: 2.1.1
       responselike: 2.0.1
 
-  got@14.4.0:
+  got@14.4.1:
     dependencies:
       '@sindresorhus/is': 6.3.1
       '@szmarczak/http-timer': 5.0.1
@@ -12531,6 +12531,7 @@ snapshots:
       lowercase-keys: 3.0.0
       p-cancelable: 4.0.1
       responselike: 3.0.0
+      type-fest: 4.19.0
 
   graceful-fs@4.2.11: {}
 

From 5d4f66d34c2eba645f9cd40688f3b4b2ffa87273 Mon Sep 17 00:00:00 2001
From: CI <project_7_bot_1bfaee5701aed20091a86249a967a6c1@noreply.firefish.dev>
Date: Thu, 6 Jun 2024 20:05:36 +0000
Subject: [PATCH 13/17] chore(deps): update dependency execa to v9.2.0

---
 package.json   |  2 +-
 pnpm-lock.yaml | 12 ++++++------
 2 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/package.json b/package.json
index e0b9ce6b4c..33b13daa34 100644
--- a/package.json
+++ b/package.json
@@ -47,7 +47,7 @@
 		"@biomejs/cli-linux-arm64": "1.8.0",
 		"@biomejs/cli-linux-x64": "1.8.0",
 		"@types/node": "20.14.2",
-		"execa": "9.1.0",
+		"execa": "9.2.0",
 		"pnpm": "9.2.0",
 		"typescript": "5.4.5"
 	}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ff5e969bf0..88d80b7a1b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -31,8 +31,8 @@ importers:
         specifier: 20.14.2
         version: 20.14.2
       execa:
-        specifier: 9.1.0
-        version: 9.1.0
+        specifier: 9.2.0
+        version: 9.2.0
       pnpm:
         specifier: 9.2.0
         version: 9.2.0
@@ -4450,9 +4450,9 @@ packages:
     resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
     engines: {node: '>=10'}
 
-  execa@9.1.0:
-    resolution: {integrity: sha512-lSgHc4Elo2m6bUDhc3Hl/VxvUDJdQWI40RZ4KMY9bKRc+hgMOT7II/JjbNDhI8VnMtrCb7U/fhpJIkLORZozWw==}
-    engines: {node: '>=18'}
+  execa@9.2.0:
+    resolution: {integrity: sha512-vpOyYg7UAVKLAWWtRS2gAdgkT7oJbCn0me3gmUmxZih4kd3MF/oo8kNTBTIbkO3yuuF5uB4ZCZfn8BOolITYhg==}
+    engines: {node: ^18.19.0 || >=20.5.0}
 
   executable@4.1.1:
     resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
@@ -12117,7 +12117,7 @@ snapshots:
       signal-exit: 3.0.7
       strip-final-newline: 2.0.0
 
-  execa@9.1.0:
+  execa@9.2.0:
     dependencies:
       '@sindresorhus/merge-streams': 4.0.0
       cross-spawn: 7.0.3

From 763cc1914e043d7e345b1d709b4f8beac22ad31b Mon Sep 17 00:00:00 2001
From: CI <project_7_bot_1bfaee5701aed20091a86249a967a6c1@noreply.firefish.dev>
Date: Thu, 6 Jun 2024 20:06:01 +0000
Subject: [PATCH 14/17] fix(deps): update dependency aws-sdk to v2.1636.0

---
 packages/backend/package.json |  2 +-
 pnpm-lock.yaml                | 10 +++++-----
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/packages/backend/package.json b/packages/backend/package.json
index 1ae5453de3..6cbd303c07 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -36,7 +36,7 @@
 		"adm-zip": "0.5.14",
 		"ajv": "8.16.0",
 		"archiver": "7.0.1",
-		"aws-sdk": "2.1635.0",
+		"aws-sdk": "2.1636.0",
 		"axios": "1.7.2",
 		"backend-rs": "workspace:*",
 		"blurhash": "2.0.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ff5e969bf0..269f1fa6f1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -85,8 +85,8 @@ importers:
         specifier: 7.0.1
         version: 7.0.1
       aws-sdk:
-        specifier: 2.1635.0
-        version: 2.1635.0
+        specifier: 2.1636.0
+        version: 2.1636.0
       axios:
         specifier: 1.7.2
         version: 1.7.2
@@ -3035,8 +3035,8 @@ packages:
     resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
     engines: {node: '>= 0.4'}
 
-  aws-sdk@2.1635.0:
-    resolution: {integrity: sha512-zab4y8ftjgcctYao33c1fr8Yx1wMuRlEbZT7hwEXcK8Ta3+LAEXJs1wKjPpium+KUwl6zw7wxhaIdEibiz6InA==}
+  aws-sdk@2.1636.0:
+    resolution: {integrity: sha512-0w/jOCYnwewLYjH4UCh3GTBjR/NMdvEKNrd1pnM4FvfJSmjfzCinDvmf5Qc6xeIrqPfrdYOoQh7NJhYeJScCIQ==}
     engines: {node: '>= 10.0.0'}
 
   axios@0.24.0:
@@ -10437,7 +10437,7 @@ snapshots:
     dependencies:
       possible-typed-array-names: 1.0.0
 
-  aws-sdk@2.1635.0:
+  aws-sdk@2.1636.0:
     dependencies:
       buffer: 4.9.2
       events: 1.1.1

From a0ebad442d7c0fd38ebdcb32017aab96c1987701 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Fri, 7 Jun 2024 07:06:22 +0900
Subject: [PATCH 15/17] chore (backend): fix import

---
 packages/backend/src/misc/translate.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/backend/src/misc/translate.ts b/packages/backend/src/misc/translate.ts
index 50fa7b2b3b..d1bcd5e72e 100644
--- a/packages/backend/src/misc/translate.ts
+++ b/packages/backend/src/misc/translate.ts
@@ -2,7 +2,7 @@ import fetch from "node-fetch";
 import { Converter } from "opencc-js";
 import { getAgentByUrl } from "@/misc/fetch.js";
 import { fetchMeta } from "backend-rs";
-import type { PostLanguage } from "@/misc/langmap";
+import type { PostLanguage } from "firefish-js";
 import * as deepl from "deepl-node";
 
 // DeepL translate and LibreTranslate don't provide

From 5daa113928b40bbcbc7f8224de767b64c54b1d36 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Fri, 7 Jun 2024 07:35:56 +0900
Subject: [PATCH 16/17] fix (backend): fix scheduled reply/quote behavior

---
 packages/backend/src/services/note/create.ts | 572 ++++++++++---------
 packages/backend/src/services/note/delete.ts |  10 +-
 2 files changed, 298 insertions(+), 284 deletions(-)

diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index 5eb1ace5e2..7d8dbed3dc 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -171,6 +171,9 @@ export default async (
 		const dontFederateInitially =
 			data.visibility?.startsWith("hidden") === true;
 
+		// Whether this is a scheduled "draft" post (yet to be published)
+		const isDraft = data.scheduledAt != null;
+
 		// If you reply outside the channel, match the scope of the target.
 		// TODO (I think it's a process that could be done on the client side, but it's server side for now.)
 		if (
@@ -270,7 +273,7 @@ export default async (
 
 		data.text = data.text?.trim() ?? null;
 
-		if (data.lang) {
+		if (data.lang != null) {
 			if (!Object.keys(langmap).includes(data.lang.toLowerCase()))
 				throw new Error("invalid param");
 			data.lang = data.lang.toLowerCase();
@@ -314,7 +317,7 @@ export default async (
 			);
 		}
 
-		if (data.visibility === "specified") {
+		if (!isDraft && data.visibility === "specified") {
 			if (data.visibleUsers == null) throw new Error("invalid param");
 
 			for (const u of data.visibleUsers) {
@@ -352,310 +355,317 @@ export default async (
 			});
 		}
 
-		// ハッシュタグ更新
-		if (data.visibility === "public" || data.visibility === "home") {
-			updateHashtags(user, tags);
-		}
+		if (!isDraft) {
+			// ハッシュタグ更新
+			if (data.visibility === "public" || data.visibility === "home") {
+				updateHashtags(user, tags);
+			}
 
-		// Increment notes count (user)
-		incNotesCountOfUser(user);
+			// Increment notes count (user)
+			incNotesCountOfUser(user);
 
-		// Word mutes & antenna
-		const thisNoteIsMutedBy: string[] = [];
+			// Word mutes & antenna
+			const thisNoteIsMutedBy: string[] = [];
 
-		await hardMutesCache
-			.fetch(null, () =>
-				UserProfiles.find({
-					where: {
-						enableWordMute: true,
-					},
-					select: ["userId", "mutedWords", "mutedPatterns"],
-				}),
-			)
-			.then(async (us) => {
-				for (const u of us) {
-					if (u.userId === user.id) return;
-					await checkWordMute(note, u.mutedWords, u.mutedPatterns).then(
-						(shouldMute: boolean) => {
-							if (shouldMute) {
-								thisNoteIsMutedBy.push(u.userId);
-								MutedNotes.insert({
-									id: genId(),
-									userId: u.userId,
-									noteId: note.id,
-									reason: "word",
-								});
-							}
+			await hardMutesCache
+				.fetch(null, () =>
+					UserProfiles.find({
+						where: {
+							enableWordMute: true,
 						},
-					);
-				}
-			});
+						select: ["userId", "mutedWords", "mutedPatterns"],
+					}),
+				)
+				.then(async (us) => {
+					for (const u of us) {
+						if (u.userId === user.id) return;
+						await checkWordMute(note, u.mutedWords, u.mutedPatterns).then(
+							(shouldMute: boolean) => {
+								if (shouldMute) {
+									thisNoteIsMutedBy.push(u.userId);
+									MutedNotes.insert({
+										id: genId(),
+										userId: u.userId,
+										noteId: note.id,
+										reason: "word",
+									});
+								}
+							},
+						);
+					}
+				});
 
-		// type errors will be resolved by https://github.com/napi-rs/napi-rs/pull/2054
-		const _note = toRustObject(note);
-		if (note.renoteId == null || isQuote(_note)) {
-			await updateAntennasOnNewNote(_note, user, thisNoteIsMutedBy);
-		}
+			// type errors will be resolved by https://github.com/napi-rs/napi-rs/pull/2054
+			const _note = toRustObject(note);
+			if (note.renoteId == null || isQuote(_note)) {
+				await updateAntennasOnNewNote(_note, user, thisNoteIsMutedBy);
+			}
 
-		// Channel
-		if (note.channelId) {
-			ChannelFollowings.findBy({ followeeId: note.channelId }).then(
-				(followings) => {
-					for (const following of followings) {
-						insertNoteUnread(following.followerId, note, {
-							isSpecified: false,
+			// Channel
+			if (note.channelId != null) {
+				ChannelFollowings.findBy({ followeeId: note.channelId }).then(
+					(followings) => {
+						for (const following of followings) {
+							insertNoteUnread(following.followerId, note, {
+								isSpecified: false,
+								isMentioned: false,
+							});
+						}
+					},
+				);
+			}
+
+			if (data.reply) {
+				saveReply(data.reply, note);
+			}
+
+			// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
+			if (
+				data.renote &&
+				!user.isBot &&
+				(await countSameRenotes(user.id, data.renote.id, note.id)) === 0
+			) {
+				incRenoteCount(data.renote);
+			}
+
+			if (data.poll?.expiresAt) {
+				const delay = data.poll.expiresAt.getTime() - Date.now();
+				endedPollNotificationQueue.add(
+					{
+						noteId: note.id,
+					},
+					{
+						delay,
+						removeOnComplete: true,
+					},
+				);
+			}
+
+			if (!silent) {
+				if (Users.isLocalUser(user)) activeUsersChart.write(user);
+
+				// 未読通知を作成
+				if (data.visibility === "specified") {
+					if (data.visibleUsers == null) throw new Error("invalid param");
+
+					for (const u of data.visibleUsers) {
+						// ローカルユーザーのみ
+						if (!Users.isLocalUser(u)) continue;
+
+						insertNoteUnread(u.id, note, {
+							isSpecified: true,
 							isMentioned: false,
 						});
 					}
-				},
-			);
-		}
-
-		if (data.reply) {
-			saveReply(data.reply, note);
-		}
-
-		// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
-		if (
-			data.renote &&
-			!user.isBot &&
-			(await countSameRenotes(user.id, data.renote.id, note.id)) === 0
-		) {
-			incRenoteCount(data.renote);
-		}
-
-		if (data.poll?.expiresAt) {
-			const delay = data.poll.expiresAt.getTime() - Date.now();
-			endedPollNotificationQueue.add(
-				{
-					noteId: note.id,
-				},
-				{
-					delay,
-					removeOnComplete: true,
-				},
-			);
-		}
-
-		if (!silent) {
-			if (Users.isLocalUser(user)) activeUsersChart.write(user);
-
-			// 未読通知を作成
-			if (data.visibility === "specified") {
-				if (data.visibleUsers == null) throw new Error("invalid param");
-
-				for (const u of data.visibleUsers) {
-					// ローカルユーザーのみ
-					if (!Users.isLocalUser(u)) continue;
-
-					insertNoteUnread(u.id, note, {
-						isSpecified: true,
-						isMentioned: false,
-					});
-				}
-			} else {
-				for (const u of mentionedUsers) {
-					// ローカルユーザーのみ
-					if (!Users.isLocalUser(u)) continue;
-
-					insertNoteUnread(u.id, note, {
-						isSpecified: false,
-						isMentioned: true,
-					});
-				}
-			}
-
-			if (!dontFederateInitially) {
-				let publishKey: string;
-				let noteToPublish: Note;
-				const relays = await getCachedRelays();
-
-				// Some relays (e.g., aode-relay) deliver posts by boosting them as
-				// Announce activities. In that case, user is the relay's actor.
-				const boostedByRelay =
-					!!user.inbox &&
-					relays.map((relay) => relay.inbox).includes(user.inbox);
-
-				if (boostedByRelay && data.renote && data.renote.userHost) {
-					publishKey = `publishedNote:${data.renote.id}`;
-					noteToPublish = data.renote;
 				} else {
-					publishKey = `publishedNote:${note.id}`;
-					noteToPublish = note;
-				}
+					for (const u of mentionedUsers) {
+						// ローカルユーザーのみ
+						if (!Users.isLocalUser(u)) continue;
 
-				const lock = new Mutex(redisClient, "publishedNote");
-				await lock.acquire();
-				try {
-					const published = (await redisClient.get(publishKey)) != null;
-					if (!published) {
-						await redisClient.set(publishKey, "done", "EX", 30);
-						if (noteToPublish.renoteId) {
-							// Prevents other threads from publishing the boosting post
-							await redisClient.set(
-								`publishedNote:${noteToPublish.renoteId}`,
-								"done",
-								"EX",
-								30,
-							);
-						}
-						publishNotesStream(noteToPublish);
-					}
-				} finally {
-					await lock.release();
-				}
-			}
-			if (note.replyId != null) {
-				// Only provide the reply note id here as the recipient may not be authorized to see the note.
-				publishNoteStream(note.replyId, "replied", {
-					id: note.id,
-				});
-			}
-
-			const webhooks = await getActiveWebhooks().then((webhooks) =>
-				webhooks.filter((x) => x.userId === user.id && x.on.includes("note")),
-			);
-
-			for (const webhook of webhooks) {
-				webhookDeliver(webhook, "note", {
-					note: await Notes.pack(note, user),
-				});
-			}
-
-			const nm = new NotificationManager(user, note);
-			const nmRelatedPromises = [];
-
-			await createMentionedEvents(mentionedUsers, note, nm);
-
-			// If has in reply to note
-			if (data.reply) {
-				// Fetch watchers
-				nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm));
-
-				// 通知
-				if (data.reply.userHost === null) {
-					const threadMuted = await NoteThreadMutings.findOneBy({
-						userId: data.reply.userId,
-						threadId: data.reply.threadId || data.reply.id,
-					});
-
-					if (!threadMuted) {
-						nm.push(data.reply.userId, "reply");
-
-						const packedReply = await Notes.pack(note, {
-							id: data.reply.userId,
+						insertNoteUnread(u.id, note, {
+							isSpecified: false,
+							isMentioned: true,
 						});
-						publishMainStream(data.reply.userId, "reply", packedReply);
+					}
+				}
 
+				if (!dontFederateInitially) {
+					let publishKey: string;
+					let noteToPublish: Note;
+					const relays = await getCachedRelays();
+
+					// Some relays (e.g., aode-relay) deliver posts by boosting them as
+					// Announce activities. In that case, user is the relay's actor.
+					const boostedByRelay =
+						!!user.inbox &&
+						relays.map((relay) => relay.inbox).includes(user.inbox);
+
+					if (boostedByRelay && data.renote && data.renote.userHost) {
+						publishKey = `publishedNote:${data.renote.id}`;
+						noteToPublish = data.renote;
+					} else {
+						publishKey = `publishedNote:${note.id}`;
+						noteToPublish = note;
+					}
+
+					const lock = new Mutex(redisClient, "publishedNote");
+					await lock.acquire();
+					try {
+						const published = (await redisClient.get(publishKey)) != null;
+						if (!published) {
+							await redisClient.set(publishKey, "done", "EX", 30);
+							if (noteToPublish.renoteId) {
+								// Prevents other threads from publishing the boosting post
+								await redisClient.set(
+									`publishedNote:${noteToPublish.renoteId}`,
+									"done",
+									"EX",
+									30,
+								);
+							}
+							publishNotesStream(noteToPublish);
+						}
+					} finally {
+						await lock.release();
+					}
+				}
+				if (note.replyId != null) {
+					// Only provide the reply note id here as the recipient may not be authorized to see the note.
+					publishNoteStream(note.replyId, "replied", {
+						id: note.id,
+					});
+				}
+
+				const webhooks = await getActiveWebhooks().then((webhooks) =>
+					webhooks.filter((x) => x.userId === user.id && x.on.includes("note")),
+				);
+
+				for (const webhook of webhooks) {
+					webhookDeliver(webhook, "note", {
+						note: await Notes.pack(note, user),
+					});
+				}
+
+				const nm = new NotificationManager(user, note);
+				const nmRelatedPromises = [];
+
+				await createMentionedEvents(mentionedUsers, note, nm);
+
+				// If has in reply to note
+				if (data.reply != null) {
+					// Fetch watchers
+					nmRelatedPromises.push(
+						notifyToWatchersOfReplyee(data.reply, user, nm),
+					);
+
+					// 通知
+					if (data.reply.userHost === null) {
+						const threadMuted = await NoteThreadMutings.findOneBy({
+							userId: data.reply.userId,
+							threadId: data.reply.threadId || data.reply.id,
+						});
+
+						if (!threadMuted) {
+							nm.push(data.reply.userId, "reply");
+
+							const packedReply = await Notes.pack(note, {
+								id: data.reply.userId,
+							});
+							publishMainStream(data.reply.userId, "reply", packedReply);
+
+							const webhooks = (await getActiveWebhooks()).filter(
+								(x) =>
+									x.userId === data.reply?.userId && x.on.includes("reply"),
+							);
+							for (const webhook of webhooks) {
+								webhookDeliver(webhook, "reply", {
+									note: packedReply,
+								});
+							}
+						}
+					}
+				}
+
+				// If it is renote
+				if (data.renote != null) {
+					const type = data.text ? "quote" : "renote";
+
+					// Notify
+					if (data.renote.userHost === null) {
+						const threadMuted = await NoteThreadMutings.findOneBy({
+							userId: data.renote.userId,
+							threadId: data.renote.threadId || data.renote.id,
+						});
+
+						if (!threadMuted) {
+							nm.push(data.renote.userId, type);
+						}
+					}
+					// Fetch watchers
+					nmRelatedPromises.push(
+						notifyToWatchersOfRenotee(data.renote, user, nm, type),
+					);
+
+					// Publish event
+					if (user.id !== data.renote.userId && data.renote.userHost === null) {
+						const packedRenote = await Notes.pack(note, {
+							id: data.renote.userId,
+						});
+						publishMainStream(data.renote.userId, "renote", packedRenote);
+
+						const renote = data.renote;
 						const webhooks = (await getActiveWebhooks()).filter(
-							(x) => x.userId === data.reply?.userId && x.on.includes("reply"),
+							(x) => x.userId === renote.userId && x.on.includes("renote"),
 						);
 						for (const webhook of webhooks) {
-							webhookDeliver(webhook, "reply", {
-								note: packedReply,
+							webhookDeliver(webhook, "renote", {
+								note: packedRenote,
 							});
 						}
 					}
 				}
+
+				Promise.all(nmRelatedPromises).then(() => {
+					nm.deliver();
+				});
+
+				//#region AP deliver
+				if (Users.isLocalUser(user) && !dontFederateInitially) {
+					(async () => {
+						const noteActivity = await renderNoteOrRenoteActivity(data, note);
+						const dm = new DeliverManager(user, noteActivity);
+
+						// メンションされたリモートユーザーに配送
+						for (const u of mentionedUsers.filter((u) =>
+							Users.isRemoteUser(u),
+						)) {
+							dm.addDirectRecipe(u as IRemoteUser);
+						}
+
+						// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
+						if (data.reply?.userHost != null) {
+							const u = await Users.findOneBy({ id: data.reply.userId });
+							if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
+						}
+
+						// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
+						if (data.renote?.userHost != null) {
+							const u = await Users.findOneBy({ id: data.renote.userId });
+							if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
+						}
+
+						// フォロワーに配送
+						if (["public", "home", "followers"].includes(note.visibility)) {
+							dm.addFollowersRecipe();
+						}
+
+						if (["public"].includes(note.visibility)) {
+							deliverToRelays(user, noteActivity);
+						}
+
+						dm.execute();
+					})();
+				}
+				//#endregion
 			}
 
-			// If it is renote
-			if (data.renote) {
-				const type = data.text ? "quote" : "renote";
+			if (data.channel) {
+				Channels.increment({ id: data.channel.id }, "notesCount", 1);
+				Channels.update(data.channel.id, {
+					lastNotedAt: new Date(),
+				});
 
-				// Notify
-				if (data.renote.userHost === null) {
-					const threadMuted = await NoteThreadMutings.findOneBy({
-						userId: data.renote.userId,
-						threadId: data.renote.threadId || data.renote.id,
-					});
-
-					if (!threadMuted) {
-						nm.push(data.renote.userId, type);
+				await Notes.countBy({
+					userId: user.id,
+					channelId: data.channel.id,
+				}).then((count) => {
+					// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
+					// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
+					if (count === 1 && data.channel != null) {
+						Channels.increment({ id: data.channel.id }, "usersCount", 1);
 					}
-				}
-				// Fetch watchers
-				nmRelatedPromises.push(
-					notifyToWatchersOfRenotee(data.renote, user, nm, type),
-				);
-
-				// Publish event
-				if (user.id !== data.renote.userId && data.renote.userHost === null) {
-					const packedRenote = await Notes.pack(note, {
-						id: data.renote.userId,
-					});
-					publishMainStream(data.renote.userId, "renote", packedRenote);
-
-					const renote = data.renote;
-					const webhooks = (await getActiveWebhooks()).filter(
-						(x) => x.userId === renote.userId && x.on.includes("renote"),
-					);
-					for (const webhook of webhooks) {
-						webhookDeliver(webhook, "renote", {
-							note: packedRenote,
-						});
-					}
-				}
+				});
 			}
-
-			Promise.all(nmRelatedPromises).then(() => {
-				nm.deliver();
-			});
-
-			//#region AP deliver
-			if (Users.isLocalUser(user) && !dontFederateInitially) {
-				(async () => {
-					const noteActivity = await renderNoteOrRenoteActivity(data, note);
-					const dm = new DeliverManager(user, noteActivity);
-
-					// メンションされたリモートユーザーに配送
-					for (const u of mentionedUsers.filter((u) => Users.isRemoteUser(u))) {
-						dm.addDirectRecipe(u as IRemoteUser);
-					}
-
-					// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
-					if (data.reply?.userHost != null) {
-						const u = await Users.findOneBy({ id: data.reply.userId });
-						if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
-					}
-
-					// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
-					if (data.renote?.userHost != null) {
-						const u = await Users.findOneBy({ id: data.renote.userId });
-						if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
-					}
-
-					// フォロワーに配送
-					if (["public", "home", "followers"].includes(note.visibility)) {
-						dm.addFollowersRecipe();
-					}
-
-					if (["public"].includes(note.visibility)) {
-						deliverToRelays(user, noteActivity);
-					}
-
-					dm.execute();
-				})();
-			}
-			//#endregion
-		}
-
-		if (data.channel) {
-			Channels.increment({ id: data.channel.id }, "notesCount", 1);
-			Channels.update(data.channel.id, {
-				lastNotedAt: new Date(),
-			});
-
-			await Notes.countBy({
-				userId: user.id,
-				channelId: data.channel.id,
-			}).then((count) => {
-				// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
-				// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
-				if (count === 1 && data.channel != null) {
-					Channels.increment({ id: data.channel.id }, "usersCount", 1);
-				}
-			});
 		}
 	});
 
diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts
index c709792fef..e6c31f0a55 100644
--- a/packages/backend/src/services/note/delete.ts
+++ b/packages/backend/src/services/note/delete.ts
@@ -42,8 +42,12 @@ export default async function (
 ) {
 	const deletedAt = new Date();
 
+	// Whether this is a scheduled "draft" post
+	const isDraft = note.scheduledAt != null;
+
 	// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
 	if (
+		!isDraft &&
 		note.renoteId &&
 		(await countSameRenotes(user.id, note.renoteId, note.id)) === 0 &&
 		deleteFromDb
@@ -52,7 +56,7 @@ export default async function (
 		Notes.decrement({ id: note.renoteId }, "score", 1);
 	}
 
-	if (note.replyId && deleteFromDb) {
+	if (!isDraft && note.replyId != null && deleteFromDb) {
 		await Notes.decrement({ id: note.replyId }, "repliesCount", 1);
 	}
 
@@ -67,14 +71,14 @@ export default async function (
 	const instanceNotesCountDecreasement: Record<string, number> = {};
 
 	// Only broadcast "deleted" to local if the note is deleted from db
-	if (deleteFromDb) {
+	if (!isDraft && deleteFromDb) {
 		publishNoteStream(note.id, "deleted", {
 			deletedAt: deletedAt,
 		});
 	}
 
 	//#region ローカルの投稿なら削除アクティビティを配送
-	if (Users.isLocalUser(user) && !note.localOnly) {
+	if (!isDraft && Users.isLocalUser(user) && !note.localOnly) {
 		let renote: Note | null = null;
 
 		// if deletd note is renote

From 9a417ee0462cf74b5047df94da4916aa8cbb54c0 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Fri, 7 Jun 2024 07:48:51 +0900
Subject: [PATCH 17/17] fix (backend): fix scheduled draft post streaming

---
 packages/backend/src/services/note/create.ts | 80 ++++++++++----------
 packages/backend/src/services/note/delete.ts |  2 +-
 2 files changed, 41 insertions(+), 41 deletions(-)

diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index 7d8dbed3dc..b37c007152 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -469,46 +469,6 @@ export default async (
 					}
 				}
 
-				if (!dontFederateInitially) {
-					let publishKey: string;
-					let noteToPublish: Note;
-					const relays = await getCachedRelays();
-
-					// Some relays (e.g., aode-relay) deliver posts by boosting them as
-					// Announce activities. In that case, user is the relay's actor.
-					const boostedByRelay =
-						!!user.inbox &&
-						relays.map((relay) => relay.inbox).includes(user.inbox);
-
-					if (boostedByRelay && data.renote && data.renote.userHost) {
-						publishKey = `publishedNote:${data.renote.id}`;
-						noteToPublish = data.renote;
-					} else {
-						publishKey = `publishedNote:${note.id}`;
-						noteToPublish = note;
-					}
-
-					const lock = new Mutex(redisClient, "publishedNote");
-					await lock.acquire();
-					try {
-						const published = (await redisClient.get(publishKey)) != null;
-						if (!published) {
-							await redisClient.set(publishKey, "done", "EX", 30);
-							if (noteToPublish.renoteId) {
-								// Prevents other threads from publishing the boosting post
-								await redisClient.set(
-									`publishedNote:${noteToPublish.renoteId}`,
-									"done",
-									"EX",
-									30,
-								);
-							}
-							publishNotesStream(noteToPublish);
-						}
-					} finally {
-						await lock.release();
-					}
-				}
 				if (note.replyId != null) {
 					// Only provide the reply note id here as the recipient may not be authorized to see the note.
 					publishNoteStream(note.replyId, "replied", {
@@ -667,6 +627,46 @@ export default async (
 				});
 			}
 		}
+
+		if (!dontFederateInitially) {
+			let publishKey: string;
+			let noteToPublish: Note;
+			const relays = await getCachedRelays();
+
+			// Some relays (e.g., aode-relay) deliver posts by boosting them as
+			// Announce activities. In that case, user is the relay's actor.
+			const boostedByRelay =
+				!!user.inbox && relays.map((relay) => relay.inbox).includes(user.inbox);
+
+			if (boostedByRelay && data.renote && data.renote.userHost) {
+				publishKey = `publishedNote:${data.renote.id}`;
+				noteToPublish = data.renote;
+			} else {
+				publishKey = `publishedNote:${note.id}`;
+				noteToPublish = note;
+			}
+
+			const lock = new Mutex(redisClient, "publishedNote");
+			await lock.acquire();
+			try {
+				const published = (await redisClient.get(publishKey)) != null;
+				if (!published) {
+					await redisClient.set(publishKey, "done", "EX", 30);
+					if (noteToPublish.renoteId) {
+						// Prevents other threads from publishing the boosting post
+						await redisClient.set(
+							`publishedNote:${noteToPublish.renoteId}`,
+							"done",
+							"EX",
+							30,
+						);
+					}
+					publishNotesStream(noteToPublish);
+				}
+			} finally {
+				await lock.release();
+			}
+		}
 	});
 
 async function renderNoteOrRenoteActivity(data: NoteLike, note: Note) {
diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts
index e6c31f0a55..966d72a036 100644
--- a/packages/backend/src/services/note/delete.ts
+++ b/packages/backend/src/services/note/delete.ts
@@ -71,7 +71,7 @@ export default async function (
 	const instanceNotesCountDecreasement: Record<string, number> = {};
 
 	// Only broadcast "deleted" to local if the note is deleted from db
-	if (!isDraft && deleteFromDb) {
+	if (deleteFromDb) {
 		publishNoteStream(note.id, "deleted", {
 			deletedAt: deletedAt,
 		});