chore: allow resolving conversation relates to deleted note

This commit is contained in:
anatawa12 2025-08-02 22:58:45 +09:00
parent 82e8c5ff25
commit 912cc0a86b
No known key found for this signature in database
GPG Key ID: 9CA909848B8E4EA6
4 changed files with 59 additions and 73 deletions

View File

@ -57,6 +57,18 @@ async function nullIfEntityNotFound<T>(promise: Promise<T>): Promise<T | null> {
}
}
type PackSingleOptions = {
detail?: boolean;
skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
_hint_?: {
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>;
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
};
};
@Injectable()
export class NoteEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
@ -357,21 +369,24 @@ export class NoteEntityService implements OnModuleInit {
return fileIds.map(id => packedFiles.get(id)).filter(x => x != null);
}
@bindThis
public async packMayDeleted(
src: MiNote | MiDeletedNote,
me?: { id: MiUser['id'] } | null | undefined,
options?: PackSingleOptions,
): Promise<Packed<'Note'>> {
if ('deletedAt' in src) {
return this.packDeletedNote(src, me, options);
} else {
return this.pack(src, me, options);
}
}
@bindThis
public async pack(
src: MiNote['id'] | MiNote,
me?: { id: MiUser['id'] } | null | undefined,
options?: {
detail?: boolean;
skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
_hint_?: {
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>;
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
};
},
options?: PackSingleOptions,
): Promise<Packed<'Note'>> {
const opts = Object.assign({
detail: true,
@ -462,7 +477,6 @@ export class NoteEntityService implements OnModuleInit {
...(opts.detail ? {
clippedCount: note.clippedCount,
// そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される
reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
detail: false,
skipHide: opts.skipHide,
@ -470,7 +484,6 @@ export class NoteEntityService implements OnModuleInit {
_hint_: options?._hint_,
}) : undefined,
// そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される
renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, {
detail: true,
skipHide: opts.skipHide,
@ -502,17 +515,7 @@ export class NoteEntityService implements OnModuleInit {
public async packDeletedNote(
srcId: MiNote['id'] | MiDeletedNote,
me?: { id: MiUser['id'] } | null | undefined,
options?: {
detail?: boolean;
skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
_hint_?: {
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>;
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
};
},
options?: PackSingleOptions,
): Promise<Packed<'Note'>> {
const opts = Object.assign({
detail: true,
@ -595,7 +598,7 @@ export class NoteEntityService implements OnModuleInit {
@bindThis
public async packMany(
notes: MiNote[],
notes: (MiNote | MiDeletedNote)[],
me?: { id: MiUser['id'] } | null | undefined,
options?: {
detail?: boolean;
@ -604,7 +607,9 @@ export class NoteEntityService implements OnModuleInit {
) {
if (notes.length === 0) return [];
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
const liveNotes = notes.filter((n): n is MiNote => !('deletedAt' in n));
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(liveNotes)]) : null;
const meId = me ? me.id : null;
const myReactionsMap = new Map<MiNote['id'], string | null>();
@ -614,7 +619,7 @@ export class NoteEntityService implements OnModuleInit {
// パフォーマンスのためートが作成されてから2秒以上経っていない場合はリアクションを取得しない
const oldId = this.idService.gen(Date.now() - 2000);
for (const note of notes) {
for (const note of liveNotes) {
if (isPureRenote(note)) {
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) {
@ -662,9 +667,9 @@ export class NoteEntityService implements OnModuleInit {
}
}
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes));
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(liveNotes));
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null);
const fileIds = liveNotes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null);
const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
const users = [
...notes.map(({ user, userId }) => user ?? userId),
@ -674,7 +679,7 @@ export class NoteEntityService implements OnModuleInit {
const packedUsers = await this.userEntityService.packMany(users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return await Promise.all(notes.map(n => this.pack(n, me, {
return await Promise.all(notes.map(n => this.packMayDeleted(n, me, {
...options,
_hint_: {
bufferedReactions,

View File

@ -9,6 +9,7 @@ import type { DeletedNotesRepository, NotesRepository, UsersRepository } from '@
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
import type { MiDeletedNote } from '@/models/DeletedNote.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
@ -43,19 +44,17 @@ export class GetterService {
}
@bindThis
public async getDeletedNote(noteId: MiNote['id']) {
const note = await this.deletedNotesRepository.findOneBy({ id: noteId });
public async getMayDeletedNoteOrNull(noteId: MiNote['id']): Promise<MiNote | MiDeletedNote | null> {
let note: MiNote | MiDeletedNote | null = await this.notesRepository.findOneBy({ id: noteId });
if (note == null) {
throw new IdentifiableError('f2d7e5b8-9d79-4996-b996-89b538a1b71f', 'No such deleted note.');
}
note ??= await this.deletedNotesRepository.findOneBy({ id: noteId });
return note;
}
@bindThis
public async getNoteWithRelations(noteId: MiNote['id']) {
const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] });
public async getMayDeletedNote(noteId: MiNote['id']): Promise<(MiNote | MiDeletedNote)> {
const note = await this.getMayDeletedNoteOrNull(noteId);
if (note == null) {
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
@ -65,14 +64,16 @@ export class GetterService {
}
@bindThis
public async getDeletedNoteWithRelations(noteId: MiNote['id']) {
const note = await this.deletedNotesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] });
public async getMayDeletedNoteWithRelations(noteId: MiNote['id']): Promise<(MiNote | MiDeletedNote) & { user: NonNullable<MiNote['user']> }> {
let note: MiNote | MiDeletedNote | null = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] });
note ??= await this.deletedNotesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] });
if (note == null) {
throw new IdentifiableError('f2d7e5b8-9d79-4996-b996-89b538a1b71f', 'No such deleted note.');
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
}
return note;
return note as (MiNote | MiDeletedNote) & { user: object };
}
/**

View File

@ -3,12 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { MiNote } from '@/models/Note.js';
import type { NotesRepository } from '@/models/_.js';
import type { MiDeletedNote } from '@/models/DeletedNote.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
@ -49,24 +48,21 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => {
const note = await this.getterService.getMayDeletedNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
const conversation: MiNote[] = [];
const conversation: (MiNote | MiDeletedNote)[] = [];
let i = 0;
const get = async (id: any) => {
const get = async (id: MiNote['id']) => {
i++;
const p = await this.notesRepository.findOneBy({ id });
const p = await this.getterService.getMayDeletedNoteOrNull(id);
if (p == null) return;
if (i > ps.offset!) {

View File

@ -57,28 +57,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
let note: MiNote | void;
try {
note = await this.getterService.getNoteWithRelations(ps.noteId);
} catch (err) {
if (err instanceof IdentifiableError && err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') {
try {
const deletedNote = await this.getterService.getDeletedNoteWithRelations(ps.noteId);
return await this.noteEntityService.packDeletedNote(deletedNote, me, {
detail: true,
});
} catch (err) {
if (err instanceof IdentifiableError && err.id === 'f2d7e5b8-9d79-4996-b996-89b538a1b71f') {
throw new ApiError(meta.errors.noSuchNote);
}
throw err;
}
}
const note = await this.getterService.getMayDeletedNoteWithRelations(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
}
});
if (note.user!.requireSigninToViewContents && me == null) {
if (note.user.requireSigninToViewContents && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
@ -86,11 +70,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.signinRequired);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.user.host != null && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
return await this.noteEntityService.pack(note, me, {
return await this.noteEntityService.packMayDeleted(note, me, {
detail: true,
});
});