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; - - @ManyToOne(() => User, { - onDelete: "CASCADE", - }) - @JoinColumn() - public user: Relation; - //#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; +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, ) => - // biome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME + // biome-ignore lint/suspicious/noAsyncPromiseExecutor: new Promise(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, ) {