From 007e57cce2ef49c2e88ec6abc39547547c6f1fe1 Mon Sep 17 00:00:00 2001 From: Tatsuto YAMAMOTO Date: Sun, 23 Jun 2024 18:11:57 +0900 Subject: [PATCH] feat: impl attach media to note --- pkg/notes/adaptor/repository/dummy.ts | 39 +++++++++++++++ pkg/notes/adaptor/repository/prisma.ts | 68 ++++++++++++++++++++++++++ pkg/notes/mod.ts | 17 ++++++- pkg/notes/model/repository.ts | 9 ++++ pkg/notes/service/create.test.ts | 47 +++++++++++++++++- pkg/notes/service/create.ts | 19 ++++++- pkg/notes/service/renote.test.ts | 53 +++++++++++++++++++- pkg/notes/service/renote.ts | 19 ++++++- 8 files changed, 265 insertions(+), 6 deletions(-) diff --git a/pkg/notes/adaptor/repository/dummy.ts b/pkg/notes/adaptor/repository/dummy.ts index 5f368e8a..4d289aa4 100644 --- a/pkg/notes/adaptor/repository/dummy.ts +++ b/pkg/notes/adaptor/repository/dummy.ts @@ -1,10 +1,12 @@ import { Option, Result } from '@mikuroxina/mini-fn'; import type { AccountID } from '../../../accounts/model/account.js'; +import { type Medium, type MediumID } from '../../../drive/model/medium.js'; import { Bookmark } from '../../model/bookmark.js'; import { type Note, type NoteID } from '../../model/note.js'; import type { BookmarkRepository, + NoteAttachmentRepository, NoteRepository, } from '../../model/repository.js'; @@ -124,3 +126,40 @@ export class InMemoryBookmarkRepository implements BookmarkRepository { return Promise.resolve(Option.some(bookmarks)); } } + +export class InMemoryNoteAttachmentRepository + implements NoteAttachmentRepository +{ + private readonly attachments: Map; + private readonly medium: Map; + + constructor(medium: Medium[]) { + this.attachments = new Map(); + this.medium = new Map(medium.map((m) => [m.getId(), m])); + } + + async create( + noteID: NoteID, + attachmentFileID: MediumID[], + ): Promise> { + if (attachmentFileID.map((v) => this.medium.has(v)).includes(false)) { + return Result.err(new Error('medium not found')); + } + + this.attachments.set(noteID, attachmentFileID); + return Result.ok(undefined); + } + + async findByNoteID(noteID: NoteID): Promise> { + const attachment = this.attachments.get(noteID); + if (!attachment) { + return Result.err(new Error('attachment not found')); + } + + // ToDo: make filter more safe (may be fix at TypeScript 5.4) + const res = attachment + .map((id) => this.medium.get(id)) + .filter((v): v is Medium => Boolean(v)); + return Result.ok(res); + } +} diff --git a/pkg/notes/adaptor/repository/prisma.ts b/pkg/notes/adaptor/repository/prisma.ts index ab566f2e..65376a34 100644 --- a/pkg/notes/adaptor/repository/prisma.ts +++ b/pkg/notes/adaptor/repository/prisma.ts @@ -3,10 +3,12 @@ import { type Prisma, type PrismaClient } from '@prisma/client'; import type { AccountID } from '../../../accounts/model/account.js'; import type { prismaClient } from '../../../adaptors/prisma.js'; +import { Medium, type MediumID } from '../../../drive/model/medium.js'; import { Bookmark } from '../../model/bookmark.js'; import { Note, type NoteID, type NoteVisibility } from '../../model/note.js'; import type { BookmarkRepository, + NoteAttachmentRepository, NoteRepository, } from '../../model/repository.js'; @@ -239,3 +241,69 @@ export class PrismaBookmarkRepository implements BookmarkRepository { } } } + +type DeserializeNoteAttachmentArgs = Prisma.PromiseReturnType< + typeof prismaClient.noteAttachment.findMany<{ include: { medium: true } }> +>; + +export class PrismaNoteAttachmentRepository + implements NoteAttachmentRepository +{ + constructor(private readonly client: PrismaClient) {} + + private deserialize(data: DeserializeNoteAttachmentArgs): Medium[] { + data.map((v) => { + const medium = v.medium; + return Medium.reconstruct({ + authorId: medium.authorId as AccountID, + hash: medium.hash, + id: medium.id as MediumID, + mime: medium.mime, + name: medium.name, + nsfw: medium.nsfw, + thumbnailUrl: medium.thumbnailUrl, + url: medium.url, + }); + }); + + return []; + } + + async create( + noteID: NoteID, + attachmentFileID: MediumID[], + ): Promise> { + const data = attachmentFileID.map((v) => { + return { + noteId: noteID, + mediumId: v, + alt: '', + }; + }); + + try { + await this.client.noteAttachment.createMany({ + data, + }); + return Result.ok(undefined); + } catch (e) { + return Result.err(e as Error); + } + } + + async findByNoteID(noteID: NoteID): Promise> { + try { + const res = await this.client.noteAttachment.findMany({ + where: { + noteId: noteID, + }, + include: { + medium: true, + }, + }); + return Result.ok(this.deserialize(res)); + } catch (e) { + return Result.err(e as Error); + } + } +} diff --git a/pkg/notes/mod.ts b/pkg/notes/mod.ts index a41a1092..18663cfc 100644 --- a/pkg/notes/mod.ts +++ b/pkg/notes/mod.ts @@ -15,10 +15,12 @@ import { BookmarkController } from './adaptor/controller/bookmark.js'; import { NoteController } from './adaptor/controller/note.js'; import { InMemoryBookmarkRepository, + InMemoryNoteAttachmentRepository, InMemoryNoteRepository, } from './adaptor/repository/dummy.js'; import { PrismaBookmarkRepository, + PrismaNoteAttachmentRepository, PrismaNoteRepository, } from './adaptor/repository/prisma.js'; import { @@ -45,6 +47,9 @@ const noteRepository = isProduction const bookmarkRepository = isProduction ? new PrismaBookmarkRepository(prismaClient) : new InMemoryBookmarkRepository(); +const attachmentRepository = isProduction + ? new PrismaNoteAttachmentRepository(prismaClient) + : new InMemoryNoteAttachmentRepository([]); const idGenerator = new SnowflakeIDGenerator(0, { now: () => BigInt(Date.now()), }); @@ -66,9 +71,17 @@ const AuthMiddleware = await Ether.runEtherT( const accountModule = new AccountModule(); // Note -const createService = new CreateService(noteRepository, idGenerator); +const createService = new CreateService( + noteRepository, + idGenerator, + attachmentRepository, +); const fetchService = new FetchService(noteRepository, accountModule); -const renoteService = new RenoteService(noteRepository, idGenerator); +const renoteService = new RenoteService( + noteRepository, + idGenerator, + attachmentRepository, +); const controller = new NoteController( createService, fetchService, diff --git a/pkg/notes/model/repository.ts b/pkg/notes/model/repository.ts index 9a471ceb..52360f8c 100644 --- a/pkg/notes/model/repository.ts +++ b/pkg/notes/model/repository.ts @@ -1,6 +1,7 @@ import type { Option, Result } from '@mikuroxina/mini-fn'; import type { AccountID } from '../../accounts/model/account.js'; +import { type Medium, type MediumID } from '../../drive/model/medium.js'; import type { Bookmark } from './bookmark.js'; import type { Note, NoteID } from './note.js'; @@ -29,3 +30,11 @@ export interface BookmarkRepository { accountID: AccountID; }): Promise>; } + +export interface NoteAttachmentRepository { + create( + noteID: NoteID, + attachmentFileID: readonly MediumID[], + ): Promise>; + findByNoteID(noteID: NoteID): Promise>; +} diff --git a/pkg/notes/service/create.test.ts b/pkg/notes/service/create.test.ts index 2ac5119c..0ade8431 100644 --- a/pkg/notes/service/create.test.ts +++ b/pkg/notes/service/create.test.ts @@ -2,16 +2,35 @@ import { Option, Result } from '@mikuroxina/mini-fn'; import { describe, expect, it } from 'vitest'; import type { AccountID } from '../../accounts/model/account.js'; +import { Medium, type MediumID } from '../../drive/model/medium.js'; import { SnowflakeIDGenerator } from '../../id/mod.js'; -import { InMemoryNoteRepository } from '../adaptor/repository/dummy.js'; +import { + InMemoryNoteAttachmentRepository, + InMemoryNoteRepository, +} from '../adaptor/repository/dummy.js'; import { CreateService } from './create.js'; const noteRepository = new InMemoryNoteRepository(); +const attachmentRepository = new InMemoryNoteAttachmentRepository( + Array.from({ length: 16 }, (_, i) => { + return Medium.reconstruct({ + id: (i + 10).toString() as MediumID, + name: (i + 10).toString(), + mime: 'image/png', + hash: 'ewkjnfgr]g:ge+ealksmc', + url: '', + thumbnailUrl: '', + nsfw: false, + authorId: '1' as AccountID, + }); + }), +); const createService = new CreateService( noteRepository, new SnowflakeIDGenerator(0, { now: () => BigInt(Date.UTC(2023, 9, 10, 0, 0)), }), + attachmentRepository, ); describe('CreateService', () => { @@ -28,6 +47,32 @@ describe('CreateService', () => { expect(Result.isOk(res)).toBe(true); }); + it('with attachments', async () => { + const res = await createService.handle( + 'Hello world', + '', + Option.none(), + '1' as AccountID, + ['10' as MediumID, '11' as MediumID], + 'PUBLIC', + ); + + expect(Result.isOk(res)).toBe(true); + }); + + it('note attachment must be less than 16', async () => { + const res = await createService.handle( + 'Hello world', + '', + Option.none(), + '1' as AccountID, + Array.from({ length: 17 }, (_, i) => i.toString() as MediumID), + 'PUBLIC', + ); + + expect(Result.isErr(res)).toBe(true); + }); + it('note content must be less than 3000 chars', async () => { const res = await createService.handle( 'a'.repeat(3001), diff --git a/pkg/notes/service/create.ts b/pkg/notes/service/create.ts index 4f006758..aa2d6a5e 100644 --- a/pkg/notes/service/create.ts +++ b/pkg/notes/service/create.ts @@ -4,7 +4,10 @@ import type { AccountID } from '../../accounts/model/account.js'; import type { MediumID } from '../../drive/model/medium.js'; import type { SnowflakeIDGenerator } from '../../id/mod.js'; import { Note, type NoteID, type NoteVisibility } from '../model/note.js'; -import type { NoteRepository } from '../model/repository.js'; +import type { + NoteAttachmentRepository, + NoteRepository, +} from '../model/repository.js'; export class CreateService { async handle( @@ -15,6 +18,9 @@ export class CreateService { attachmentFileID: MediumID[], visibility: NoteVisibility, ): Promise> { + if (attachmentFileID.length > 16) { + return Result.err(new Error('Too many attachments')); + } const id = this.idGenerator.generate(); if (Result.isErr(id)) { return id; @@ -36,6 +42,16 @@ export class CreateService { return res; } + if (attachmentFileID.length !== 0) { + const attachmentRes = await this.noteAttachmentRepository.create( + note.getID(), + note.getAttachmentFileID(), + ); + if (Result.isErr(attachmentRes)) { + return attachmentRes; + } + } + return Result.ok(note); } catch (e) { return Result.err(e as unknown as Error); @@ -45,5 +61,6 @@ export class CreateService { constructor( private readonly noteRepository: NoteRepository, private readonly idGenerator: SnowflakeIDGenerator, + private readonly noteAttachmentRepository: NoteAttachmentRepository, ) {} } diff --git a/pkg/notes/service/renote.test.ts b/pkg/notes/service/renote.test.ts index e4d6f996..11d36b1c 100644 --- a/pkg/notes/service/renote.test.ts +++ b/pkg/notes/service/renote.test.ts @@ -2,8 +2,12 @@ import { Option, Result } from '@mikuroxina/mini-fn'; import { describe, expect, it, vi } from 'vitest'; import type { AccountID } from '../../accounts/model/account.js'; +import { Medium, type MediumID } from '../../drive/model/medium.js'; import { SnowflakeIDGenerator } from '../../id/mod.js'; -import { InMemoryNoteRepository } from '../adaptor/repository/dummy.js'; +import { + InMemoryNoteAttachmentRepository, + InMemoryNoteRepository, +} from '../adaptor/repository/dummy.js'; import { Note, type NoteID } from '../model/note.js'; import { RenoteService } from './renote.js'; @@ -19,11 +23,26 @@ const originalNote = Note.new({ visibility: 'PUBLIC', }); const repository = new InMemoryNoteRepository([originalNote]); +const attachmentRepository = new InMemoryNoteAttachmentRepository( + Array.from({ length: 16 }, (_, i) => { + return Medium.reconstruct({ + id: (i + 10).toString() as MediumID, + name: (i + 10).toString(), + mime: 'image/png', + hash: 'ewkjnfgr]g:ge+ealksmc', + url: '', + thumbnailUrl: '', + nsfw: false, + authorId: '1' as AccountID, + }); + }), +); const service = new RenoteService( repository, new SnowflakeIDGenerator(0, { now: () => BigInt(Date.UTC(2023, 9, 10, 0, 0)), }), + attachmentRepository, ); describe('RenoteService', () => { @@ -45,6 +64,37 @@ describe('RenoteService', () => { expect(Result.unwrap(renote).getVisibility()).toBe('PUBLIC'); }); + it('renote with attachments', async () => { + const renote = await service.handle( + '2' as NoteID, + 'renote', + '', + '1' as AccountID, + ['10' as MediumID, '11' as MediumID], + 'PUBLIC', + ); + + expect(Result.unwrap(renote).getContent()).toBe('renote'); + expect(Result.unwrap(renote).getCwComment()).toBe(''); + expect(Result.unwrap(renote).getOriginalNoteID()).toStrictEqual( + Option.some('2' as NoteID), + ); + expect(Result.unwrap(renote).getVisibility()).toBe('PUBLIC'); + }); + + it('renote attachment must be less than 16', async () => { + const res = await service.handle( + '2' as NoteID, + 'renote', + '', + '1' as AccountID, + Array.from({ length: 17 }, (_, i) => i.toString() as MediumID), + 'PUBLIC', + ); + + expect(Result.isErr(res)).toBe(true); + }); + it('should not create renote with DIRECT visibility', async () => { const res = await service.handle( '2' as NoteID, @@ -77,6 +127,7 @@ describe('RenoteService', () => { new SnowflakeIDGenerator(0, { now: () => BigInt(Date.UTC(0, 0, 0, 0)), }), + attachmentRepository, ); const res = await dummyService.handle( diff --git a/pkg/notes/service/renote.ts b/pkg/notes/service/renote.ts index 7824caa9..7fc1b6ab 100644 --- a/pkg/notes/service/renote.ts +++ b/pkg/notes/service/renote.ts @@ -5,12 +5,16 @@ import type { MediumID } from '../../drive/model/medium.js'; import type { SnowflakeIDGenerator } from '../../id/mod.js'; import type { NoteID, NoteVisibility } from '../model/note.js'; import { Note } from '../model/note.js'; -import type { NoteRepository } from '../model/repository.js'; +import type { + NoteAttachmentRepository, + NoteRepository, +} from '../model/repository.js'; export class RenoteService { constructor( private readonly noteRepository: NoteRepository, private readonly idGenerator: SnowflakeIDGenerator, + private readonly noteAttachmentRepository: NoteAttachmentRepository, ) {} async handle( @@ -24,6 +28,9 @@ export class RenoteService { if (visibility === 'DIRECT') { return Result.err(new Error('Renote must not be direct note')); } + if (attachmentFileID.length > 16) { + return Result.err(new Error('Too many attachments')); + } // ToDo: check renote-able const originalNote = await this.noteRepository.findByID(originalNoteID); @@ -54,6 +61,16 @@ export class RenoteService { return res; } + if (attachmentFileID.length !== 0) { + const attachmentRes = await this.noteAttachmentRepository.create( + renote.getID(), + renote.getAttachmentFileID(), + ); + if (Result.isErr(attachmentRes)) { + return attachmentRes; + } + } + return Result.ok(renote); } }