diff --git a/pkg/accounts/adaptor/controller/account.ts b/pkg/accounts/adaptor/controller/account.ts index 92828f6b..3d4f9608 100644 --- a/pkg/accounts/adaptor/controller/account.ts +++ b/pkg/accounts/adaptor/controller/account.ts @@ -1,14 +1,17 @@ import type { z } from '@hono/zod-openapi'; import { Option, Result } from '@mikuroxina/mini-fn'; +import type { Medium, MediumID } from '../../../drive/model/medium.js'; import type { AccountID, AccountName } from '../../model/account.js'; import type { AccountFollow } from '../../model/follow.js'; import type { AuthenticateService } from '../../service/authenticate.js'; +import type { AccountAvatarService } from '../../service/avatar.js'; import type { EditService } from '../../service/edit.js'; import type { FetchService } from '../../service/fetch.js'; import type { FetchFollowService } from '../../service/fetchFollow.js'; import type { FollowService } from '../../service/follow.js'; import type { FreezeService } from '../../service/freeze.js'; +import type { AccountHeaderService } from '../../service/header.js'; import type { RegisterService } from '../../service/register.js'; import type { ResendVerifyTokenService } from '../../service/resendToken.js'; import type { SilenceService } from '../../service/silence.js'; @@ -35,6 +38,8 @@ export class AccountController { private readonly unFollowService: UnfollowService; private readonly fetchFollowService: FetchFollowService; private readonly resendTokenService: ResendVerifyTokenService; + private readonly headerService: AccountHeaderService; + private readonly avatarService: AccountAvatarService; constructor(args: { registerService: RegisterService; @@ -48,6 +53,8 @@ export class AccountController { unFollowService: UnfollowService; fetchFollowService: FetchFollowService; resendTokenService: ResendVerifyTokenService; + headerService: AccountHeaderService; + avatarService: AccountAvatarService; }) { this.registerService = args.registerService; this.editService = args.editService; @@ -60,6 +67,8 @@ export class AccountController { this.unFollowService = args.unFollowService; this.fetchFollowService = args.fetchFollowService; this.resendTokenService = args.resendTokenService; + this.headerService = args.headerService; + this.avatarService = args.avatarService; } async createAccount( @@ -212,24 +221,38 @@ export class AccountController { if (Result.isErr(res)) { return res; } + const account = Result.unwrap(res); + + const avatarRes = await this.avatarService.fetchByAccountID( + account.getID(), + ); + const headerRes = await this.headerService.fetchByAccountID( + account.getID(), + ); + const avatar = Result.mapOr('')((avatarImage: Medium): string => + avatarImage.getUrl(), + )(avatarRes); + const header = Result.mapOr('')((headerImage: Medium): string => + headerImage.getUrl(), + )(headerRes); return Result.ok({ - id: res[1].getID(), - email: res[1].getMail(), - name: res[1].getName() as string, - nickname: res[1].getNickname(), - bio: res[1].getBio(), + id: account.getID(), + email: account.getMail(), + name: account.getName() as string, + nickname: account.getNickname(), + bio: account.getBio(), // ToDo: fill the following fields - avatar: '', - header: '', + avatar: avatar, + header: header, followed_count: 0, following_count: 0, note_count: 0, - created_at: res[1].getCreatedAt(), - role: res[1].getRole(), - frozen: res[1].getFrozen(), - status: res[1].getStatus(), - silenced: res[1].getSilenced(), + created_at: account.getCreatedAt(), + role: account.getRole(), + frozen: account.getFrozen(), + status: account.getStatus(), + silenced: account.getSilenced(), }); } @@ -240,24 +263,38 @@ export class AccountController { if (Result.isErr(res)) { return res; } + const account = Result.unwrap(res); + + const avatarRes = await this.avatarService.fetchByAccountID( + account.getID(), + ); + const headerRes = await this.headerService.fetchByAccountID( + account.getID(), + ); + const avatar = Result.mapOr('')((avatarImage: Medium): string => + avatarImage.getUrl(), + )(avatarRes); + const header = Result.mapOr('')((headerImage: Medium): string => + headerImage.getUrl(), + )(headerRes); return Result.ok({ - id: res[1].getID(), - email: res[1].getMail(), - name: res[1].getName() as string, - nickname: res[1].getNickname(), - bio: res[1].getBio(), + id: account.getID(), + email: account.getMail(), + name: account.getName() as string, + nickname: account.getNickname(), + bio: account.getBio(), // ToDo: fill the following fields - avatar: '', - header: '', + avatar: avatar, + header: header, followed_count: 0, following_count: 0, note_count: 0, - created_at: res[1].getCreatedAt(), - role: res[1].getRole(), - frozen: res[1].getFrozen(), - status: res[1].getStatus(), - silenced: res[1].getSilenced(), + created_at: account.getCreatedAt(), + role: account.getRole(), + frozen: account.getFrozen(), + status: account.getStatus(), + silenced: account.getSilenced(), }); } @@ -438,4 +475,80 @@ export class AccountController { }), ); } + + async setAvatar( + targetAccountName: string, + actorID: string, + medium: string, + ): Promise> { + const accountRes = await this.fetchService.fetchAccount( + targetAccountName as AccountName, + ); + if (Result.isErr(accountRes)) { + return accountRes; + } + const account = Result.unwrap(accountRes); + + return await this.avatarService.create( + account.getID(), + medium as MediumID, + actorID as AccountID, + ); + } + + async setHeader( + targetAccountName: string, + actorID: string, + medium: string, + ): Promise> { + const accountRes = await this.fetchService.fetchAccount( + targetAccountName as AccountName, + ); + if (Result.isErr(accountRes)) { + return accountRes; + } + const account = Result.unwrap(accountRes); + + return await this.headerService.create( + account.getID(), + medium as MediumID, + actorID as AccountID, + ); + } + + async unsetAvatar( + targetAccountName: string, + actorID: string, + ): Promise> { + const accountRes = await this.fetchService.fetchAccount( + targetAccountName as AccountName, + ); + if (Result.isErr(accountRes)) { + return accountRes; + } + const account = Result.unwrap(accountRes); + + return await this.avatarService.delete( + account.getID(), + actorID as AccountID, + ); + } + + async unsetHeader( + targetAccountName: string, + actorID: string, + ): Promise> { + const accountRes = await this.fetchService.fetchAccount( + targetAccountName as AccountID, + ); + if (Result.isErr(accountRes)) { + return accountRes; + } + const account = Result.unwrap(accountRes); + + return await this.headerService.delete( + account.getID(), + actorID as AccountID, + ); + } } diff --git a/pkg/accounts/adaptor/repository/dummy.ts b/pkg/accounts/adaptor/repository/dummy.ts deleted file mode 100644 index fe8f5af6..00000000 --- a/pkg/accounts/adaptor/repository/dummy.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { Ether, Option, Result } from '@mikuroxina/mini-fn'; - -import type { Account, AccountID } from '../../model/account.js'; -import { AccountNotFoundError } from '../../model/errors.js'; -import type { AccountFollow } from '../../model/follow.js'; -import type { InactiveAccount } from '../../model/inactiveAccount.js'; -import { - type AccountFollowRepository, - type AccountRepository, - type AccountVerifyTokenRepository, - type InactiveAccountRepository, - accountRepoSymbol, - followRepoSymbol, - inactiveAccountRepoSymbol, - verifyTokenRepoSymbol, -} from '../../model/repository.js'; - -export class InMemoryAccountRepository implements AccountRepository { - private data: Set; - - constructor(accounts: Account[] = []) { - this.data = new Set(accounts); - } - - create(account: Account): Promise> { - this.data.add(account); - return Promise.resolve(Result.ok(undefined)); - } - - reset(data: Account[] = []): void { - this.data.clear(); - data.map((v) => this.data.add(v)); - } - - findByID(id: AccountID): Promise> { - const account = Array.from(this.data).find((a) => a.getID() === id); - if (!account) { - return Promise.resolve(Option.none()); - } - - return Promise.resolve(Option.some(account)); - } - - findByName(name: string): Promise> { - const account = Array.from(this.data).find((a) => a.getName() === name); - if (!account) { - return Promise.resolve(Option.none()); - } - return Promise.resolve(Option.some(account)); - } - - findManyByID( - id: readonly AccountID[], - ): Promise> { - const set = new Set(id); - const accounts = Array.from(this.data).filter((a) => set.has(a.getID())); - return Promise.resolve(Result.ok(accounts)); - } - - findByMail(mail: string): Promise> { - const account = Array.from(this.data).find((a) => a.getMail() === mail); - if (!account) { - return Promise.resolve(Option.none()); - } - - return Promise.resolve(Option.some(account)); - } - - async edit(account: Account): Promise> { - const oldAccount = Array.from(this.data).find( - (a) => a.getName() === account.getName(), - ); - if (oldAccount) { - this.data.delete(oldAccount); - } - this.data.add(account); - - return Result.ok(undefined); - } -} -export const newAccountRepo = (accounts: Account[] = []) => - Ether.newEther( - accountRepoSymbol, - () => new InMemoryAccountRepository(accounts), - ); - -export class InMemoryAccountVerifyTokenRepository - implements AccountVerifyTokenRepository -{ - private data: Map; - - constructor() { - this.data = new Map(); - } - - create( - accountID: AccountID, - token: string, - expire: Date, - ): Promise> { - this.data.set(accountID.toString(), { token, expire }); - return Promise.resolve(Result.ok(undefined)); - } - - findByID( - id: AccountID, - ): Promise> { - const data = this.data.get(id); - if (!data) { - return Promise.resolve(Option.none()); - } - - return Promise.resolve(Option.some(data)); - } -} -export const verifyTokenRepo = Ether.newEther( - verifyTokenRepoSymbol, - () => new InMemoryAccountVerifyTokenRepository(), -); - -export class InMemoryAccountFollowRepository - implements AccountFollowRepository -{ - private readonly data: Set; - - constructor(data?: AccountFollow[]) { - this.data = new Set(data); - } - - async fetchAllFollowers( - accountID: AccountID, - ): Promise> { - const res = [...this.data].filter((f) => f.getTargetID() === accountID); - return Result.ok(res); - } - - async fetchAllFollowing( - accountID: AccountID, - ): Promise> { - const res = [...this.data].filter((f) => f.getFromID() === accountID); - return Result.ok(res); - } - - async follow(follow: AccountFollow): Promise> { - this.data.add(follow); - return Result.ok(undefined); - } - - async unfollow( - accountID: AccountID, - targetID: AccountID, - ): Promise> { - const follow = [...this.data].find( - (f) => f.getFromID() === accountID && f.getTargetID() === targetID, - ); - if (!follow) { - return Result.err(new AccountNotFoundError('not found', { cause: null })); - } - - this.data.delete(follow); - return Result.ok(undefined); - } - - async fetchOrderedFollowers( - accountID: AccountID, - limit: number, - ): Promise> { - return Result.ok( - [...this.data] - .filter((f) => f.getTargetID() === accountID) - .sort((a, b) => { - return a.getCreatedAt().getTime() - b.getCreatedAt().getTime(); - }) - .slice(0, limit), - ); - } - - async fetchOrderedFollowing( - accountID: AccountID, - limit: number, - ): Promise> { - return Result.ok( - [...this.data] - .filter((f) => f.getFromID() === accountID) - .sort((a, b) => { - return a.getCreatedAt().getTime() - b.getCreatedAt().getTime(); - }) - .slice(0, limit), - ); - } -} -export const newFollowRepo = (data?: AccountFollow[]) => - Ether.newEther( - followRepoSymbol, - () => new InMemoryAccountFollowRepository(data), - ); - -export class InMemoryInactiveAccountRepository - implements InactiveAccountRepository -{ - private data: Set; - - constructor() { - this.data = new Set(); - } - - create(account: InactiveAccount): Promise> { - this.data.add(account); - return Promise.resolve(Result.ok(undefined)); - } - - reset(): void { - this.data.clear(); - } - - findByName(name: string): Promise> { - const account = Array.from(this.data).find((a) => a.getName() === name); - if (!account) { - return Promise.resolve(Option.none()); - } - return Promise.resolve(Option.some(account)); - } - - findByMail(mail: string): Promise> { - const account = Array.from(this.data).find((a) => a.getMail() === mail); - if (!account) { - return Promise.resolve(Option.none()); - } - - return Promise.resolve(Option.some(account)); - } -} -export const inactiveAccountRepo = Ether.newEther( - inactiveAccountRepoSymbol, - () => new InMemoryInactiveAccountRepository(), -); diff --git a/pkg/accounts/adaptor/repository/dummy.test.ts b/pkg/accounts/adaptor/repository/dummy/account.test.ts similarity index 92% rename from pkg/accounts/adaptor/repository/dummy.test.ts rename to pkg/accounts/adaptor/repository/dummy/account.test.ts index 089495b8..e3dbba91 100644 --- a/pkg/accounts/adaptor/repository/dummy.test.ts +++ b/pkg/accounts/adaptor/repository/dummy/account.test.ts @@ -1,7 +1,8 @@ import { Result } from '@mikuroxina/mini-fn'; import { describe, expect, it } from 'vitest'; -import { Account, type AccountID } from '../../model/account.js'; -import { InMemoryAccountRepository } from './dummy.js'; +import { Account, type AccountID } from '../../../model/account.js'; + +import { InMemoryAccountRepository } from './account.js'; describe('InMemoryAccountRepository', () => { const dummyInput: Account[] = [ diff --git a/pkg/accounts/adaptor/repository/dummy/account.ts b/pkg/accounts/adaptor/repository/dummy/account.ts new file mode 100644 index 00000000..7d005ddf --- /dev/null +++ b/pkg/accounts/adaptor/repository/dummy/account.ts @@ -0,0 +1,76 @@ +import { Ether, Option, Result } from '@mikuroxina/mini-fn'; +import type { Account, AccountID } from '../../../model/account.js'; +import { + type AccountRepository, + accountRepoSymbol, +} from '../../../model/repository.js'; + +export class InMemoryAccountRepository implements AccountRepository { + private data: Set; + + constructor(accounts: Account[] = []) { + this.data = new Set(accounts); + } + + create(account: Account): Promise> { + this.data.add(account); + return Promise.resolve(Result.ok(undefined)); + } + + reset(data: Account[] = []): void { + this.data.clear(); + data.map((v) => this.data.add(v)); + } + + findByID(id: AccountID): Promise> { + const account = Array.from(this.data).find((a) => a.getID() === id); + if (!account) { + return Promise.resolve(Option.none()); + } + + return Promise.resolve(Option.some(account)); + } + + findByName(name: string): Promise> { + const account = Array.from(this.data).find((a) => a.getName() === name); + if (!account) { + return Promise.resolve(Option.none()); + } + return Promise.resolve(Option.some(account)); + } + + findManyByID( + id: readonly AccountID[], + ): Promise> { + const set = new Set(id); + const accounts = Array.from(this.data).filter((a) => set.has(a.getID())); + return Promise.resolve(Result.ok(accounts)); + } + + findByMail(mail: string): Promise> { + const account = Array.from(this.data).find((a) => a.getMail() === mail); + if (!account) { + return Promise.resolve(Option.none()); + } + + return Promise.resolve(Option.some(account)); + } + + async edit(account: Account): Promise> { + const oldAccount = Array.from(this.data).find( + (a) => a.getName() === account.getName(), + ); + if (oldAccount) { + this.data.delete(oldAccount); + } + this.data.add(account); + + return Result.ok(undefined); + } +} + +export const newAccountRepo = (accounts: Account[] = []) => + Ether.newEther( + accountRepoSymbol, + () => new InMemoryAccountRepository(accounts), + ); diff --git a/pkg/accounts/adaptor/repository/dummy/avatar.ts b/pkg/accounts/adaptor/repository/dummy/avatar.ts new file mode 100644 index 00000000..5992412b --- /dev/null +++ b/pkg/accounts/adaptor/repository/dummy/avatar.ts @@ -0,0 +1,78 @@ +import { Ether, Result } from '@mikuroxina/mini-fn'; +import { MediaNotFoundError } from '../../../../drive/model/errors.js'; +import type { Medium, MediumID } from '../../../../drive/model/medium.js'; +import type { AccountID } from '../../../model/account.js'; +import { AccountNotFoundError } from '../../../model/errors.js'; +import { + type AccountAvatarRepository, + accountAvatarRepoSymbol, +} from '../../../model/repository.js'; + +export class InMemoryAccountAvatarRepository + implements AccountAvatarRepository +{ + private media: Map; + private data: Map; + constructor( + media: Medium[] = [], + accountAvatar: { accountID: AccountID; mediumID: MediumID }[] = [], + ) { + this.media = new Map(media.map((m) => [m.getId(), m])); + this.data = new Map( + accountAvatar.map(({ accountID, mediumID }) => [accountID, mediumID]), + ); + } + + reset( + media: Medium[] = [], + accountAvatar: { accountID: AccountID; mediumID: MediumID }[] = [], + ) { + this.media = new Map(media.map((m) => [m.getId(), m])); + this.data = new Map( + accountAvatar.map(({ accountID, mediumID }) => [accountID, mediumID]), + ); + } + + async create( + accountID: AccountID, + mediumID: MediumID, + ): Promise> { + if (this.data.has(accountID)) { + // ToDo: Define AccountHeaderAlreadyExistsError + return Result.err(new Error('Account already exists')); + } + + this.data.set(accountID, mediumID); + + return Result.ok(undefined); + } + + async delete(accountID: AccountID): Promise> { + if (!this.data.has(accountID)) { + return Result.err( + new AccountNotFoundError('Account not found', { cause: null }), + ); + } + this.data.delete(accountID); + return Result.ok(undefined); + } + + async findByID(accountID: AccountID): Promise> { + const mediumID = this.data.get(accountID); + if (!mediumID) { + return Result.err( + new MediaNotFoundError('medium not found', { cause: null }), + ); + } + + return Result.ok(this.media.get(mediumID) as Medium); + } +} +export const inMemoryAccountAvatarRepo = ( + media: Medium[], + accountAvatar: { accountID: AccountID; mediumID: MediumID }[], +) => + Ether.newEther( + accountAvatarRepoSymbol, + () => new InMemoryAccountAvatarRepository(media, accountAvatar), + ); diff --git a/pkg/accounts/adaptor/repository/dummy/follow.ts b/pkg/accounts/adaptor/repository/dummy/follow.ts new file mode 100644 index 00000000..15911472 --- /dev/null +++ b/pkg/accounts/adaptor/repository/dummy/follow.ts @@ -0,0 +1,86 @@ +import { Ether, Result } from '@mikuroxina/mini-fn'; +import type { AccountID } from '../../../model/account.js'; +import { AccountNotFoundError } from '../../../model/errors.js'; +import type { AccountFollow } from '../../../model/follow.js'; +import { + type AccountFollowRepository, + followRepoSymbol, +} from '../../../model/repository.js'; + +export class InMemoryAccountFollowRepository + implements AccountFollowRepository +{ + private readonly data: Set; + + constructor(data?: AccountFollow[]) { + this.data = new Set(data); + } + + async fetchAllFollowers( + accountID: AccountID, + ): Promise> { + const res = [...this.data].filter((f) => f.getTargetID() === accountID); + return Result.ok(res); + } + + async fetchAllFollowing( + accountID: AccountID, + ): Promise> { + const res = [...this.data].filter((f) => f.getFromID() === accountID); + return Result.ok(res); + } + + async follow(follow: AccountFollow): Promise> { + this.data.add(follow); + return Result.ok(undefined); + } + + async unfollow( + accountID: AccountID, + targetID: AccountID, + ): Promise> { + const follow = [...this.data].find( + (f) => f.getFromID() === accountID && f.getTargetID() === targetID, + ); + if (!follow) { + return Result.err(new AccountNotFoundError('not found', { cause: null })); + } + + this.data.delete(follow); + return Result.ok(undefined); + } + + async fetchOrderedFollowers( + accountID: AccountID, + limit: number, + ): Promise> { + return Result.ok( + [...this.data] + .filter((f) => f.getTargetID() === accountID) + .sort((a, b) => { + return a.getCreatedAt().getTime() - b.getCreatedAt().getTime(); + }) + .slice(0, limit), + ); + } + + async fetchOrderedFollowing( + accountID: AccountID, + limit: number, + ): Promise> { + return Result.ok( + [...this.data] + .filter((f) => f.getFromID() === accountID) + .sort((a, b) => { + return a.getCreatedAt().getTime() - b.getCreatedAt().getTime(); + }) + .slice(0, limit), + ); + } +} + +export const newFollowRepo = (data?: AccountFollow[]) => + Ether.newEther( + followRepoSymbol, + () => new InMemoryAccountFollowRepository(data), + ); diff --git a/pkg/accounts/adaptor/repository/dummy/header.ts b/pkg/accounts/adaptor/repository/dummy/header.ts new file mode 100644 index 00000000..d974665a --- /dev/null +++ b/pkg/accounts/adaptor/repository/dummy/header.ts @@ -0,0 +1,78 @@ +import { Ether, Result } from '@mikuroxina/mini-fn'; +import { MediaNotFoundError } from '../../../../drive/model/errors.js'; +import type { Medium, MediumID } from '../../../../drive/model/medium.js'; +import type { AccountID } from '../../../model/account.js'; +import { AccountNotFoundError } from '../../../model/errors.js'; +import { + type AccountHeaderRepository, + accountHeaderRepoSymbol, +} from '../../../model/repository.js'; + +export class InMemoryAccountHeaderRepository + implements AccountHeaderRepository +{ + private media: Map; + private data: Map; + constructor( + media: Medium[] = [], + accountHeader: { accountID: AccountID; mediumID: MediumID }[] = [], + ) { + this.media = new Map(media.map((m) => [m.getId(), m])); + this.data = new Map( + accountHeader.map(({ accountID, mediumID }) => [accountID, mediumID]), + ); + } + + reset( + media: Medium[] = [], + accountHeader: { accountID: AccountID; mediumID: MediumID }[] = [], + ) { + this.media = new Map(media.map((m) => [m.getId(), m])); + this.data = new Map( + accountHeader.map(({ accountID, mediumID }) => [accountID, mediumID]), + ); + } + + async create( + accountID: AccountID, + mediumID: MediumID, + ): Promise> { + if (this.data.has(accountID)) { + // ToDo: Define AccountHeaderAlreadyExistsError + return Result.err(new Error('Account already exists')); + } + + this.data.set(accountID, mediumID); + + return Result.ok(undefined); + } + + async delete(accountID: AccountID): Promise> { + if (!this.data.has(accountID)) { + return Result.err( + new AccountNotFoundError('Account not found', { cause: null }), + ); + } + this.data.delete(accountID); + return Result.ok(undefined); + } + + async findByID(accountID: AccountID): Promise> { + const mediumID = this.data.get(accountID); + if (!mediumID) { + return Result.err( + new MediaNotFoundError('medium not found', { cause: null }), + ); + } + + return Result.ok(this.media.get(mediumID) as Medium); + } +} +export const inMemoryAccountHeaderRepo = ( + media: Medium[], + accountHeader: { accountID: AccountID; mediumID: MediumID }[], +) => + Ether.newEther( + accountHeaderRepoSymbol, + () => new InMemoryAccountHeaderRepository(media, accountHeader), + ); diff --git a/pkg/accounts/adaptor/repository/dummy/inactiveAccount.ts b/pkg/accounts/adaptor/repository/dummy/inactiveAccount.ts new file mode 100644 index 00000000..bb27a144 --- /dev/null +++ b/pkg/accounts/adaptor/repository/dummy/inactiveAccount.ts @@ -0,0 +1,47 @@ +import { Ether, Option, Result } from '@mikuroxina/mini-fn'; +import type { InactiveAccount } from '../../../model/inactiveAccount.js'; +import { + type InactiveAccountRepository, + inactiveAccountRepoSymbol, +} from '../../../model/repository.js'; + +export class InMemoryInactiveAccountRepository + implements InactiveAccountRepository +{ + private data: Set; + + constructor() { + this.data = new Set(); + } + + create(account: InactiveAccount): Promise> { + this.data.add(account); + return Promise.resolve(Result.ok(undefined)); + } + + reset(): void { + this.data.clear(); + } + + findByName(name: string): Promise> { + const account = Array.from(this.data).find((a) => a.getName() === name); + if (!account) { + return Promise.resolve(Option.none()); + } + return Promise.resolve(Option.some(account)); + } + + findByMail(mail: string): Promise> { + const account = Array.from(this.data).find((a) => a.getMail() === mail); + if (!account) { + return Promise.resolve(Option.none()); + } + + return Promise.resolve(Option.some(account)); + } +} + +export const inactiveAccountRepo = Ether.newEther( + inactiveAccountRepoSymbol, + () => new InMemoryInactiveAccountRepository(), +); diff --git a/pkg/accounts/adaptor/repository/dummy/verifyToken.ts b/pkg/accounts/adaptor/repository/dummy/verifyToken.ts new file mode 100644 index 00000000..7280333f --- /dev/null +++ b/pkg/accounts/adaptor/repository/dummy/verifyToken.ts @@ -0,0 +1,41 @@ +import { Ether, Option, Result } from '@mikuroxina/mini-fn'; +import type { AccountID } from '../../../model/account.js'; +import { + type AccountVerifyTokenRepository, + verifyTokenRepoSymbol, +} from '../../../model/repository.js'; + +export class InMemoryAccountVerifyTokenRepository + implements AccountVerifyTokenRepository +{ + private data: Map; + + constructor() { + this.data = new Map(); + } + + create( + accountID: AccountID, + token: string, + expire: Date, + ): Promise> { + this.data.set(accountID.toString(), { token, expire }); + return Promise.resolve(Result.ok(undefined)); + } + + findByID( + id: AccountID, + ): Promise> { + const data = this.data.get(id); + if (!data) { + return Promise.resolve(Option.none()); + } + + return Promise.resolve(Option.some(data)); + } +} + +export const verifyTokenRepo = Ether.newEther( + verifyTokenRepoSymbol, + () => new InMemoryAccountVerifyTokenRepository(), +); diff --git a/pkg/accounts/adaptor/repository/prisma/avatar.ts b/pkg/accounts/adaptor/repository/prisma/avatar.ts new file mode 100644 index 00000000..14138918 --- /dev/null +++ b/pkg/accounts/adaptor/repository/prisma/avatar.ts @@ -0,0 +1,91 @@ +import { Ether, Result } from '@mikuroxina/mini-fn'; +import type { Prisma, PrismaClient } from '@prisma/client'; +import type { prismaClient } from '../../../../adaptors/prisma.js'; +import { Medium, type MediumID } from '../../../../drive/model/medium.js'; +import type { AccountID } from '../../../model/account.js'; +import { AccountInternalError } from '../../../model/errors.js'; +import { + type AccountAvatarRepository, + accountAvatarRepoSymbol, +} from '../../../model/repository.js'; +import { parsePrismaError } from './prisma.js'; + +type AccountAvatarData = Prisma.PromiseReturnType< + typeof prismaClient.accountAvatar.findMany<{ include: { medium: true } }> +>; + +export class PrismaAccountAvatarRepository implements AccountAvatarRepository { + constructor(private readonly prisma: PrismaClient) {} + + async create( + accountID: AccountID, + mediumID: MediumID, + ): Promise> { + try { + await this.prisma.accountAvatar.create({ + data: { + accountId: accountID, + mediumId: mediumID, + }, + }); + } catch (e) { + return Result.err(parsePrismaError(e)); + } + return Result.ok(undefined); + } + + async delete(accountID: AccountID): Promise> { + try { + await this.prisma.accountAvatar.delete({ + where: { + accountId: accountID, + }, + }); + return Result.ok(undefined); + } catch (e) { + return Result.err(parsePrismaError(e)); + } + } + + async findByID(accountID: AccountID): Promise> { + try { + const res = await this.prisma.accountAvatar.findUniqueOrThrow({ + where: { + accountId: accountID, + }, + include: { + medium: true, + }, + }); + + return Result.ok(this.fromPrismaData([res])); + } catch (e) { + return Result.err(parsePrismaError(e)); + } + } + + fromPrismaData(arg: AccountAvatarData): Medium { + if (!arg[0]) { + throw new AccountInternalError('Account Avatar parsing failed', { + cause: null, + }); + } + const data = arg[0].medium; + + return Medium.reconstruct({ + id: data.id as MediumID, + authorId: data.authorId as AccountID, + hash: data.hash, + mime: data.mime, + name: data.name, + nsfw: data.nsfw, + thumbnailUrl: data.thumbnailUrl, + url: data.url, + }); + } +} +export const prismaAccountAvatarRepo = (prisma: PrismaClient) => + Ether.newEther( + accountAvatarRepoSymbol, + () => new PrismaAccountAvatarRepository(prisma), + ); diff --git a/pkg/accounts/adaptor/repository/prisma/header.ts b/pkg/accounts/adaptor/repository/prisma/header.ts new file mode 100644 index 00000000..54314d46 --- /dev/null +++ b/pkg/accounts/adaptor/repository/prisma/header.ts @@ -0,0 +1,91 @@ +import { Ether, Result } from '@mikuroxina/mini-fn'; +import type { Prisma, PrismaClient } from '@prisma/client'; +import type { prismaClient } from '../../../../adaptors/prisma.js'; +import { Medium, type MediumID } from '../../../../drive/model/medium.js'; +import type { AccountID } from '../../../model/account.js'; +import { AccountInternalError } from '../../../model/errors.js'; +import { + type AccountHeaderRepository, + accountHeaderRepoSymbol, +} from '../../../model/repository.js'; +import { parsePrismaError } from './prisma.js'; + +type AccountHeaderData = Prisma.PromiseReturnType< + typeof prismaClient.accountHeader.findMany<{ include: { medium: true } }> +>; + +export class PrismaAccountHeaderRepository implements AccountHeaderRepository { + constructor(private readonly prisma: PrismaClient) {} + + async create( + accountID: AccountID, + mediumID: MediumID, + ): Promise> { + try { + await this.prisma.accountHeader.create({ + data: { + accountId: accountID, + mediumId: mediumID, + }, + }); + } catch (e) { + return Result.err(parsePrismaError(e)); + } + return Result.ok(undefined); + } + + async delete(accountID: AccountID): Promise> { + try { + await this.prisma.accountHeader.delete({ + where: { + accountId: accountID, + }, + }); + return Result.ok(undefined); + } catch (e) { + return Result.err(parsePrismaError(e)); + } + } + + async findByID(accountID: AccountID): Promise> { + try { + const res = await this.prisma.accountHeader.findUniqueOrThrow({ + where: { + accountId: accountID, + }, + include: { + medium: true, + }, + }); + + return Result.ok(this.fromPrismaData([res])); + } catch (e) { + return Result.err(parsePrismaError(e)); + } + } + + fromPrismaData(arg: AccountHeaderData): Medium { + if (!arg[0]) { + throw new AccountInternalError('Account Header parsing failed', { + cause: null, + }); + } + const data = arg[0].medium; + + return Medium.reconstruct({ + id: data.id as MediumID, + authorId: data.authorId as AccountID, + hash: data.hash, + mime: data.mime, + name: data.name, + nsfw: data.nsfw, + thumbnailUrl: data.thumbnailUrl, + url: data.url, + }); + } +} +export const prismaAccountHeaderRepo = (prisma: PrismaClient) => + Ether.newEther( + accountHeaderRepoSymbol, + () => new PrismaAccountHeaderRepository(prisma), + ); diff --git a/pkg/accounts/adaptor/repository/prisma.ts b/pkg/accounts/adaptor/repository/prisma/prisma.ts similarity index 97% rename from pkg/accounts/adaptor/repository/prisma.ts rename to pkg/accounts/adaptor/repository/prisma/prisma.ts index 2ffb4951..86f2f68c 100644 --- a/pkg/accounts/adaptor/repository/prisma.ts +++ b/pkg/accounts/adaptor/repository/prisma/prisma.ts @@ -1,7 +1,7 @@ import { Ether, Option, Result } from '@mikuroxina/mini-fn'; import { Prisma, type PrismaClient } from '@prisma/client'; -import type { prismaClient } from '../../../adaptors/prisma.js'; +import type { prismaClient } from '../../../../adaptors/prisma.js'; import { Account, type AccountFrozen, @@ -10,12 +10,12 @@ import { type AccountRole, type AccountSilenced, type AccountStatus, -} from '../../model/account.js'; +} from '../../../model/account.js'; import { AccountInternalError, AccountNotFoundError, -} from '../../model/errors.js'; -import { AccountFollow } from '../../model/follow.js'; +} from '../../../model/errors.js'; +import { AccountFollow } from '../../../model/follow.js'; import { type AccountFollowRepository, type AccountRepository, @@ -23,7 +23,7 @@ import { accountRepoSymbol, followRepoSymbol, verifyTokenRepoSymbol, -} from '../../model/repository.js'; +} from '../../../model/repository.js'; type AccountPrismaArgs = Prisma.PromiseReturnType< typeof prismaClient.account.findUnique diff --git a/pkg/accounts/adaptor/validator/schema.ts b/pkg/accounts/adaptor/validator/schema.ts index 48893b90..f4ee5e90 100644 --- a/pkg/accounts/adaptor/validator/schema.ts +++ b/pkg/accounts/adaptor/validator/schema.ts @@ -259,3 +259,14 @@ export const GetAccountFollowingSchema = z export const GetAccountFollowerSchema = z .array(GetAccountResponseSchema) .openapi('GetAccountFollowerResponse'); + +// this is base schema. don't use directly / export this variable. +const SetAccountImageRequestBaseSchema = z.object({ + medium_id: z.string().openapi({ + description: 'Medium ID', + example: '38477395', + }), +}); + +export const SetAccountAvatarRequestSchema = SetAccountImageRequestBaseSchema; +export const SetAccountHeaderRequestSchema = SetAccountImageRequestBaseSchema; diff --git a/pkg/accounts/mod.ts b/pkg/accounts/mod.ts index c4611a4b..d8c357f9 100644 --- a/pkg/accounts/mod.ts +++ b/pkg/accounts/mod.ts @@ -6,21 +6,25 @@ import { authenticateMiddleware, } from '../adaptors/authenticateMiddleware.js'; import { prismaClient } from '../adaptors/prisma.js'; +import { MediaNotFoundError } from '../drive/model/errors.js'; import { clockSymbol, snowflakeIDGenerator } from '../id/mod.js'; +import { mediaModuleFacadeEther } from '../intermodule/media.js'; import { argon2idPasswordEncoder } from '../password/mod.js'; import { newTurnstileCaptchaValidator } from './adaptor/captcha/turnstile.js'; import { AccountController } from './adaptor/controller/account.js'; import { captchaMiddleware } from './adaptor/middileware/captcha.js'; -import { - InMemoryAccountRepository, - newFollowRepo, - verifyTokenRepo, -} from './adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from './adaptor/repository/dummy/account.js'; +import { inMemoryAccountAvatarRepo } from './adaptor/repository/dummy/avatar.js'; +import { newFollowRepo } from './adaptor/repository/dummy/follow.js'; +import { inMemoryAccountHeaderRepo } from './adaptor/repository/dummy/header.js'; +import { verifyTokenRepo } from './adaptor/repository/dummy/verifyToken.js'; +import { prismaAccountAvatarRepo } from './adaptor/repository/prisma/avatar.js'; +import { prismaAccountHeaderRepo } from './adaptor/repository/prisma/header.js'; import { PrismaAccountRepository, prismaFollowRepo, prismaVerifyTokenRepo, -} from './adaptor/repository/prisma.js'; +} from './adaptor/repository/prisma/prisma.js'; import type { AccountName } from './model/account.js'; import { AccountAlreadyFollowingError, @@ -52,21 +56,27 @@ import { LoginRoute, RefreshRoute, ResendVerificationEmailRoute, + SetAccountAvatarRoute, + SetAccountHeaderRoute, SilenceAccountRoute, UnFollowAccountRoute, UnFreezeAccountRoute, UnSilenceAccountRoute, + UnsetAccountAvatarRoute, + UnsetAccountHeaderRoute, UpdateAccountRoute, VerifyEmailRoute, } from './router.js'; import { authenticate } from './service/authenticate.js'; import { authenticateToken } from './service/authenticationTokenService.js'; +import { accountAvatar } from './service/avatar.js'; import { edit } from './service/edit.js'; import { etag } from './service/etagService.js'; import { fetch } from './service/fetch.js'; import { fetchFollow } from './service/fetchFollow.js'; import { follow } from './service/follow.js'; import { freeze } from './service/freeze.js'; +import { accountHeader } from './service/header.js'; import { register } from './service/register.js'; import { resendToken } from './service/resendToken.js'; import { dummy } from './service/sendNotification.js'; @@ -91,6 +101,12 @@ const accountRepository = Ether.newEther( const accountFollowRepository = isProduction ? prismaFollowRepo(prismaClient) : newFollowRepo(); +const accountHeaderRepository = isProduction + ? prismaAccountHeaderRepo(prismaClient) + : inMemoryAccountHeaderRepo([], []); +const accountAvatarRepository = isProduction + ? prismaAccountAvatarRepo(prismaClient) + : inMemoryAccountAvatarRepo([], []); class Clock { now() { @@ -168,6 +184,16 @@ export const controller = new AccountController({ .feed(Ether.compose(accountFollowRepository)) .feed(Ether.compose(accountRepository)).value, ), + headerService: Ether.runEther( + Cat.cat(accountHeader) + .feed(Ether.compose(accountHeaderRepository)) + .feed(Ether.compose(mediaModuleFacadeEther)).value, + ), + avatarService: Ether.runEther( + Cat.cat(accountAvatar) + .feed(Ether.compose(accountAvatarRepository)) + .feed(Ether.compose(mediaModuleFacadeEther)).value, + ), }); // ToDo: load secret from config file @@ -357,17 +383,18 @@ accounts.openapi(GetAccountRoute, async (c) => { } return c.json({ error: 'INTERNAL_ERROR' as const }, 500); } + const account = Result.unwrap(res); return c.json( { - id: res[1].id, - name: res[1].name, - nickname: res[1].nickname, - bio: res[1].bio, - avatar: '', - header: '', - followed_count: res[1].followed_count, - following_count: res[1].following_count, - note_count: res[1].note_count, + id: account.id, + name: account.name, + nickname: account.nickname, + bio: account.bio, + avatar: account.avatar, + header: account.header, + followed_count: account.followed_count, + following_count: account.following_count, + note_count: account.note_count, }, 200, ); @@ -382,17 +409,18 @@ accounts.openapi(GetAccountRoute, async (c) => { } return c.json({ error: 'INTERNAL_ERROR' as const }, 500); } + const account = Result.unwrap(res); return c.json( { - id: res[1].id, - name: res[1].name, - nickname: res[1].nickname, - bio: res[1].bio, - avatar: '', - header: '', - followed_count: res[1].followed_count, - following_count: res[1].following_count, - note_count: res[1].note_count, + id: account.id, + name: account.name, + nickname: account.nickname, + bio: account.bio, + avatar: account.avatar, + header: account.header, + followed_count: account.followed_count, + following_count: account.following_count, + note_count: account.note_count, }, 200, ); @@ -608,3 +636,107 @@ accounts.openapi(GetAccountFollowerRoute, async (c) => { 200, ); }); + +accounts[SetAccountAvatarRoute.method]( + SetAccountAvatarRoute.path, + AuthMiddleware.handle({ forceAuthorized: true }), +); +accounts.openapi(SetAccountAvatarRoute, async (c) => { + const { name } = c.req.valid('param'); + const { medium_id } = c.req.valid('json'); + const actorID = Option.unwrap(c.get('accountID')); + + const res = await controller.setAvatar(name, actorID, medium_id); + if (Result.isErr(res)) { + const error = Result.unwrapErr(res); + + if (error instanceof AccountNotFoundError) { + return c.json({ error: 'ACCOUNT_NOT_FOUND' as const }, 404); + } + if (error instanceof AccountInsufficientPermissionError) { + return c.json({ error: 'NO_PERMISSION' as const }, 403); + } + if (error instanceof MediaNotFoundError) { + return c.json({ error: 'FILE_NOT_FOUND' as const }, 404); + } + return c.json({ error: 'INTERNAL_ERROR' as const }, 500); + } + + return new Response(null, { status: 204 }); +}); + +accounts[UnsetAccountAvatarRoute.method]( + UnsetAccountAvatarRoute.path, + AuthMiddleware.handle({ forceAuthorized: true }), +); +accounts.openapi(UnsetAccountAvatarRoute, async (c) => { + const { name } = c.req.valid('param'); + const actorID = Option.unwrap(c.get('accountID')); + + const res = await controller.unsetAvatar(name, actorID); + if (Result.isErr(res)) { + const error = Result.unwrapErr(res); + + if (error instanceof AccountNotFoundError) { + return c.json({ error: 'ACCOUNT_NOT_FOUND' as const }, 404); + } + if (error instanceof AccountInsufficientPermissionError) { + return c.json({ error: 'NO_PERMISSION' as const }, 403); + } + return c.json({ error: 'INTERNAL_ERROR' as const }, 500); + } + + return new Response(null, { status: 204 }); +}); + +accounts[SetAccountHeaderRoute.method]( + SetAccountHeaderRoute.path, + AuthMiddleware.handle({ forceAuthorized: true }), +); +accounts.openapi(SetAccountHeaderRoute, async (c) => { + const { name } = c.req.valid('param'); + const { medium_id } = c.req.valid('json'); + const actorID = Option.unwrap(c.get('accountID')); + + const res = await controller.setHeader(name, actorID, medium_id); + if (Result.isErr(res)) { + const error = Result.unwrapErr(res); + + if (error instanceof AccountNotFoundError) { + return c.json({ error: 'ACCOUNT_NOT_FOUND' as const }, 404); + } + if (error instanceof AccountInsufficientPermissionError) { + return c.json({ error: 'NO_PERMISSION' as const }, 403); + } + if (error instanceof MediaNotFoundError) { + return c.json({ error: 'FILE_NOT_FOUND' as const }, 404); + } + return c.json({ error: 'INTERNAL_ERROR' as const }, 500); + } + + return new Response(null, { status: 204 }); +}); + +accounts[UnsetAccountHeaderRoute.method]( + UnsetAccountHeaderRoute.path, + AuthMiddleware.handle({ forceAuthorized: true }), +); +accounts.openapi(UnsetAccountHeaderRoute, async (c) => { + const actorID = Option.unwrap(c.get('accountID')); + const { name } = c.req.valid('param'); + + const res = await controller.unsetHeader(name, actorID); + if (Result.isErr(res)) { + const error = Result.unwrapErr(res); + + if (error instanceof AccountNotFoundError) { + return c.json({ error: 'ACCOUNT_NOT_FOUND' as const }, 404); + } + if (error instanceof AccountInsufficientPermissionError) { + return c.json({ error: 'NO_PERMISSION' as const }, 403); + } + return c.json({ error: 'INTERNAL_ERROR' as const }, 500); + } + + return new Response(null, { status: 204 }); +}); diff --git a/pkg/accounts/model/repository.ts b/pkg/accounts/model/repository.ts index ed88d23c..b62209b9 100644 --- a/pkg/accounts/model/repository.ts +++ b/pkg/accounts/model/repository.ts @@ -1,7 +1,7 @@ import { Ether, type Option, type Result } from '@mikuroxina/mini-fn'; -import type { Account } from './account.js'; -import type { AccountID } from './account.js'; +import type { Medium, MediumID } from '../../drive/model/medium.js'; +import type { Account, AccountID } from './account.js'; import type { AccountFollow } from './follow.js'; import type { InactiveAccount } from './inactiveAccount.js'; @@ -61,3 +61,37 @@ export interface AccountFollowRepository { ): Promise>; } export const followRepoSymbol = Ether.newEtherSymbol(); + +export interface AccountAvatarRepository { + /** + * Set an avatar image to account.\ + * NOTE: This method **WILL NOT** overwrite the existing avatar. (returns error) + * @param accountID + * @param mediumID + */ + create( + accountID: AccountID, + mediumID: MediumID, + ): Promise>; + findByID(accountID: AccountID): Promise>; + delete(accountID: AccountID): Promise>; +} +export const accountAvatarRepoSymbol = + Ether.newEtherSymbol(); + +export interface AccountHeaderRepository { + /** + * Set a header image to account.\ + * NOTE: This method **WILL NOT** overwrite the existing header. (returns error) + * @param accountID + * @param mediumID + */ + create( + accountID: AccountID, + mediumID: MediumID, + ): Promise>; + findByID(accountID: AccountID): Promise>; + delete(accountID: AccountID): Promise>; +} +export const accountHeaderRepoSymbol = + Ether.newEtherSymbol(); diff --git a/pkg/accounts/router.ts b/pkg/accounts/router.ts index 87213d86..2d826564 100644 --- a/pkg/accounts/router.ts +++ b/pkg/accounts/router.ts @@ -1,5 +1,6 @@ import { createRoute, z } from '@hono/zod-openapi'; +import { FileNotFound } from '../drive/adaptor/presenter/errors.js'; import { AccountAlreadyVerified, AccountNameInUse, @@ -32,6 +33,7 @@ import { LoginResponseSchema, RefreshRequestSchema, ResendVerificationEmailRequestSchema, + SetAccountAvatarRequestSchema, UpdateAccountRequestSchema, UpdateAccountResponseSchema, VerifyEmailRequestSchema, @@ -1000,3 +1002,261 @@ export const GetAccountFollowerRoute = createRoute({ }, }, }); + +export const SetAccountAvatarRoute = createRoute({ + method: 'post', + tags: ['accounts'], + path: '/accounts/:name/avatar', + security: [ + { + bearer: [], + }, + ], + request: { + params: z.object({ + name: z.string().min(3).max(64).openapi({ + example: 'example_man', + description: + 'Characters must be [A-Za-z0-9-.] The first and last characters must be [A-Za-z0-9-.]', + }), + }), + body: { + content: { + 'application/json': { + schema: SetAccountAvatarRequestSchema, + }, + }, + }, + }, + responses: { + 204: { + description: 'No Content', + }, + 403: { + description: 'Forbidden', + content: { + 'application/json': { + schema: z + .object({ + error: NoPermission, + }) + .openapi({ + description: 'You can not do this action.', + }), + }, + }, + }, + 404: { + description: 'Not Found', + content: { + 'application/json': { + schema: z + .object({ + error: z.union([AccountNotFound, FileNotFound]), + }) + .openapi({ + description: 'account not found', + }), + }, + }, + }, + 500: { + description: 'Internal Server Error', + content: { + 'application/json': { + schema: InternalErrorResponseSchema, + }, + }, + }, + }, +}); + +export const UnsetAccountAvatarRoute = createRoute({ + method: 'delete', + tags: ['accounts'], + path: '/accounts/:name/avatar', + security: [ + { + bearer: [], + }, + ], + request: { + params: z.object({ + name: z.string().min(3).max(64).openapi({ + example: 'example_man', + description: + 'Characters must be [A-Za-z0-9-.] The first and last characters must be [A-Za-z0-9-.]', + }), + }), + }, + responses: { + 204: { + description: 'No Content', + }, + 403: { + description: 'Forbidden', + content: { + 'application/json': { + schema: z + .object({ + error: NoPermission, + }) + .openapi({ + description: 'You can not do this action.', + }), + }, + }, + }, + 404: { + description: 'Not Found', + content: { + 'application/json': { + schema: z + .object({ + error: AccountNotFound, + }) + .openapi({ + description: 'account not found', + }), + }, + }, + }, + 500: { + description: 'Internal Server Error', + content: { + 'application/json': { + schema: InternalErrorResponseSchema, + }, + }, + }, + }, +}); + +export const SetAccountHeaderRoute = createRoute({ + method: 'post', + tags: ['accounts'], + path: '/accounts/:name/header', + security: [ + { + bearer: [], + }, + ], + request: { + params: z.object({ + name: z.string().min(3).max(64).openapi({ + example: 'example_man', + description: + 'Characters must be [A-Za-z0-9-.] The first and last characters must be [A-Za-z0-9-.]', + }), + }), + body: { + content: { + 'application/json': { + schema: SetAccountAvatarRequestSchema, + }, + }, + }, + }, + responses: { + 204: { + description: 'No Content', + }, + 403: { + description: 'Forbidden', + content: { + 'application/json': { + schema: z + .object({ + error: NoPermission, + }) + .openapi({ + description: 'You can not do this action.', + }), + }, + }, + }, + 404: { + description: 'Not Found', + content: { + 'application/json': { + schema: z + .object({ + error: z.union([AccountNotFound, FileNotFound]), + }) + .openapi({ + description: 'account not found', + }), + }, + }, + }, + 500: { + description: 'Internal Server Error', + content: { + 'application/json': { + schema: InternalErrorResponseSchema, + }, + }, + }, + }, +}); + +export const UnsetAccountHeaderRoute = createRoute({ + method: 'delete', + tags: ['accounts'], + path: '/accounts/:name/head', + security: [ + { + bearer: [], + }, + ], + request: { + params: z.object({ + name: z.string().min(3).max(64).openapi({ + example: 'example_man', + description: + 'Characters must be [A-Za-z0-9-.] The first and last characters must be [A-Za-z0-9-.]', + }), + }), + }, + responses: { + 204: { + description: 'No Content', + }, + 403: { + description: 'Forbidden', + content: { + 'application/json': { + schema: z + .object({ + error: NoPermission, + }) + .openapi({ + description: 'You can not do this action.', + }), + }, + }, + }, + 404: { + description: 'Not Found', + content: { + 'application/json': { + schema: z + .object({ + error: AccountNotFound, + }) + .openapi({ + description: 'account not found', + }), + }, + }, + }, + 500: { + description: 'Internal Server Error', + content: { + 'application/json': { + schema: InternalErrorResponseSchema, + }, + }, + }, + }, +}); diff --git a/pkg/accounts/service/authenticate.test.ts b/pkg/accounts/service/authenticate.test.ts index 6e26c666..b45991ea 100644 --- a/pkg/accounts/service/authenticate.test.ts +++ b/pkg/accounts/service/authenticate.test.ts @@ -2,7 +2,7 @@ import { Result } from '@mikuroxina/mini-fn'; import { describe, expect, it } from 'vitest'; import { Argon2idPasswordEncoder } from '../../password/mod.js'; -import { InMemoryAccountRepository } from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; import { Account, type AccountID } from '../model/account.js'; import { AuthenticateService } from './authenticate.js'; import { AuthenticationTokenService } from './authenticationTokenService.js'; diff --git a/pkg/accounts/service/avatar.test.ts b/pkg/accounts/service/avatar.test.ts new file mode 100644 index 00000000..8e516e1e --- /dev/null +++ b/pkg/accounts/service/avatar.test.ts @@ -0,0 +1,99 @@ +import { Result } from '@mikuroxina/mini-fn'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { InMemoryMediaRepository } from '../../drive/adaptor/repository/dummy.js'; +import type { MediumID } from '../../drive/model/medium.js'; +import { + testMedium, + testNSFWMedium, + testOtherMedium, +} from '../../drive/testData/testData.js'; +import { dummyMediaModuleFacade } from '../../intermodule/media.js'; +import { InMemoryAccountAvatarRepository } from '../adaptor/repository/dummy/avatar.js'; +import type { AccountID } from '../model/account.js'; +import { AccountInsufficientPermissionError } from '../model/errors.js'; +import { AccountAvatarService } from './avatar.js'; + +describe('AccountAvatarService', () => { + const avatarRepository = new InMemoryAccountAvatarRepository(); + beforeEach(() => { + avatarRepository.reset([testMedium, testNSFWMedium, testOtherMedium]); + }); + + const mediaModule = dummyMediaModuleFacade( + new InMemoryMediaRepository([testMedium, testNSFWMedium, testOtherMedium]), + ); + const service = new AccountAvatarService(avatarRepository, mediaModule); + + it('should set account avatar image', async () => { + const res = await service.create( + '101' as AccountID, + '300' as MediumID, + '101' as AccountID, + ); + expect(Result.isOk(res)).toBe(true); + }); + + it('should not set account avatar image if medium is NSFW', async () => { + const res = await service.create( + '101' as AccountID, + '301' as MediumID, + '101' as AccountID, + ); + expect(Result.isErr(res)).toBe(true); + }); + + it('should unset account avatar image', async () => { + await service.create( + '101' as AccountID, + '300' as MediumID, + '101' as AccountID, + ); + + const res = await service.delete('101' as AccountID, '101' as AccountID); + expect(Result.isOk(res)).toBe(true); + }); + + it("should fetch account's avatar image", async () => { + await service.create( + '101' as AccountID, + '300' as MediumID, + '101' as AccountID, + ); + + const res = await avatarRepository.findByID('101' as AccountID); + expect(Result.isOk(res)).toBe(true); + expect(Result.unwrap(res).getId()).toBe('300'); + }); + + it('set: actor must same as target account', async () => { + const res = await service.create( + '101' as AccountID, + '300' as MediumID, + '1' as AccountID, + ); + expect(Result.isErr(res)).toBe(true); + expect(Result.unwrapErr(res)).toBeInstanceOf( + AccountInsufficientPermissionError, + ); + }); + + it('set: medium author must same as actor', async () => { + const res = await service.create( + '101' as AccountID, + testOtherMedium.getId(), + '101' as AccountID, + ); + expect(Result.isErr(res)).toBe(true); + expect(Result.unwrapErr(res)).toBeInstanceOf( + AccountInsufficientPermissionError, + ); + }); + + it('unset: actor must same as target account', async () => { + const res = await service.delete('101' as AccountID, '1' as AccountID); + expect(Result.isErr(res)).toBe(true); + expect(Result.unwrapErr(res)).toBeInstanceOf( + AccountInsufficientPermissionError, + ); + }); +}); diff --git a/pkg/accounts/service/avatar.ts b/pkg/accounts/service/avatar.ts new file mode 100644 index 00000000..08d53578 --- /dev/null +++ b/pkg/accounts/service/avatar.ts @@ -0,0 +1,152 @@ +import { Ether, Result } from '@mikuroxina/mini-fn'; +import type { Medium, MediumID } from '../../drive/model/medium.js'; +import { + type MediaModuleFacade, + mediaModuleFacadeSymbol, +} from '../../intermodule/media.js'; +import type { AccountID } from '../model/account.js'; +import { AccountInsufficientPermissionError } from '../model/errors.js'; +import { + type AccountAvatarRepository, + accountAvatarRepoSymbol, +} from '../model/repository.js'; + +export class AccountAvatarService { + constructor( + private readonly avatarRepository: AccountAvatarRepository, + private readonly mediaModule: MediaModuleFacade, + ) {} + + /** + * @description Set account avatar image. + * + * avatar specification: + * - NSFW Media can't be used as avatar image. + * - Media must be image type(ToDo). + * - Media author must be actor. + * + * @param accountID + * @param mediumID + * @param actorID + */ + async create( + accountID: AccountID, + mediumID: MediumID, + actorID: AccountID, + ): Promise> { + const mediumRes = await this.mediaModule.fetchMedia(mediumID); + if (Result.isErr(mediumRes)) { + return mediumRes; + } + const medium = Result.unwrap(mediumRes); + if (medium.isNsfw()) { + return Result.err( + new AccountInsufficientPermissionError( + "NSFW media can't be used as avatar image", + { cause: null }, + ), + ); + } + // ToDo: Check media type + + const isAllowedRes = this.isAllowed('set', actorID, { + targetAccount: accountID, + medium, + }); + if (Result.isErr(isAllowedRes)) { + return isAllowedRes; + } + + const res = await this.avatarRepository.create(accountID, mediumID); + if (Result.isErr(res)) { + return res; + } + + return Result.ok(undefined); + } + + /** + * @description Unset account avatar image. + * @param accountID + * @param actorID + */ + async delete( + accountID: AccountID, + actorID: AccountID, + ): Promise> { + const isAllowedRes = this.isAllowed('unset', actorID, { + targetAccount: accountID, + }); + if (Result.isErr(isAllowedRes)) { + return isAllowedRes; + } + + const res = await this.avatarRepository.delete(accountID); + if (Result.isErr(res)) { + return res; + } + + return Result.ok(undefined); + } + + /** + * @description Fetch account avatar image metadata. + * @param accountID + */ + async fetchByAccountID( + accountID: AccountID, + ): Promise> { + return await this.avatarRepository.findByID(accountID); + } + + private isAllowed( + action: 'set' | 'unset', + actor: AccountID, + resources: { targetAccount: AccountID; medium?: Medium }, + ): Result.Result { + switch (action) { + case 'set': + // NOTE: actor must be same as target. + if (actor !== resources.targetAccount) { + return Result.err( + new AccountInsufficientPermissionError( + 'Actor must be same as target', + { cause: null }, + ), + ); + } + // NOTE: media author must be actor. + if (resources.medium?.getAuthorId() !== actor) { + return Result.err( + new AccountInsufficientPermissionError( + 'Media author must be actor', + { cause: null }, + ), + ); + } + return Result.ok(undefined); + case 'unset': + // NOTE: actor must be same as target. + if (actor !== resources.targetAccount) { + return Result.err( + new AccountInsufficientPermissionError( + 'Actor must be same as target', + { cause: null }, + ), + ); + } + + return Result.ok(undefined); + } + } +} +export const accountAvatarSymbol = Ether.newEtherSymbol(); +export const accountAvatar = Ether.newEther( + accountAvatarSymbol, + ({ avatarRepository, mediaModule }) => + new AccountAvatarService(avatarRepository, mediaModule), + { + avatarRepository: accountAvatarRepoSymbol, + mediaModule: mediaModuleFacadeSymbol, + }, +); diff --git a/pkg/accounts/service/edit.test.ts b/pkg/accounts/service/edit.test.ts index 6baf7e2d..adceae44 100644 --- a/pkg/accounts/service/edit.test.ts +++ b/pkg/accounts/service/edit.test.ts @@ -2,7 +2,7 @@ import { Option, Result } from '@mikuroxina/mini-fn'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Argon2idPasswordEncoder } from '../../password/mod.js'; -import { InMemoryAccountRepository } from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; import { Account, type AccountID } from '../model/account.js'; import { EditService } from './edit.js'; import { EtagService } from './etagService.js'; diff --git a/pkg/accounts/service/etagService.test.ts b/pkg/accounts/service/etagService.test.ts index 66d37f7d..6645f017 100644 --- a/pkg/accounts/service/etagService.test.ts +++ b/pkg/accounts/service/etagService.test.ts @@ -1,7 +1,7 @@ import { Option } from '@mikuroxina/mini-fn'; import { afterEach, describe, expect, it } from 'vitest'; -import { InMemoryAccountRepository } from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; import { Account, type AccountID } from '../model/account.js'; import { EtagService } from './etagService.js'; diff --git a/pkg/accounts/service/fetch.test.ts b/pkg/accounts/service/fetch.test.ts index 0584b9b7..5382c9be 100644 --- a/pkg/accounts/service/fetch.test.ts +++ b/pkg/accounts/service/fetch.test.ts @@ -1,7 +1,7 @@ import { Result } from '@mikuroxina/mini-fn'; import { afterEach, describe, expect, it } from 'vitest'; -import { InMemoryAccountRepository } from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; import { Account, type AccountID } from '../model/account.js'; import { FetchService } from './fetch.js'; diff --git a/pkg/accounts/service/fetchFollow.test.ts b/pkg/accounts/service/fetchFollow.test.ts index f66114d8..5ab9ba3d 100644 --- a/pkg/accounts/service/fetchFollow.test.ts +++ b/pkg/accounts/service/fetchFollow.test.ts @@ -1,10 +1,8 @@ import { Result } from '@mikuroxina/mini-fn'; import { describe, expect, it } from 'vitest'; -import { - InMemoryAccountFollowRepository, - InMemoryAccountRepository, -} from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; +import { InMemoryAccountFollowRepository } from '../adaptor/repository/dummy/follow.js'; import { Account, type AccountID } from '../model/account.js'; import { AccountFollow } from '../model/follow.js'; import { FetchFollowService } from './fetchFollow.js'; diff --git a/pkg/accounts/service/follow.test.ts b/pkg/accounts/service/follow.test.ts index 52f29ddd..017e77d5 100644 --- a/pkg/accounts/service/follow.test.ts +++ b/pkg/accounts/service/follow.test.ts @@ -1,10 +1,8 @@ import { Option, Result } from '@mikuroxina/mini-fn'; import { describe, expect, it } from 'vitest'; -import { - InMemoryAccountFollowRepository, - InMemoryAccountRepository, -} from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; +import { InMemoryAccountFollowRepository } from '../adaptor/repository/dummy/follow.js'; import { Account, type AccountID } from '../model/account.js'; import { FollowService } from './follow.js'; diff --git a/pkg/accounts/service/freeze.test.ts b/pkg/accounts/service/freeze.test.ts index e31153cb..8e19d4ac 100644 --- a/pkg/accounts/service/freeze.test.ts +++ b/pkg/accounts/service/freeze.test.ts @@ -1,7 +1,7 @@ import { Option, Result } from '@mikuroxina/mini-fn'; import { beforeEach, describe, expect, it } from 'vitest'; -import { InMemoryAccountRepository } from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; import { Account, type AccountID } from '../model/account.js'; import { FreezeService } from './freeze.js'; diff --git a/pkg/accounts/service/header.test.ts b/pkg/accounts/service/header.test.ts new file mode 100644 index 00000000..bd448fcd --- /dev/null +++ b/pkg/accounts/service/header.test.ts @@ -0,0 +1,99 @@ +import { Result } from '@mikuroxina/mini-fn'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { InMemoryMediaRepository } from '../../drive/adaptor/repository/dummy.js'; +import type { MediumID } from '../../drive/model/medium.js'; +import { + testMedium, + testNSFWMedium, + testOtherMedium, +} from '../../drive/testData/testData.js'; +import { dummyMediaModuleFacade } from '../../intermodule/media.js'; +import { InMemoryAccountHeaderRepository } from '../adaptor/repository/dummy/header.js'; +import type { AccountID } from '../model/account.js'; +import { AccountInsufficientPermissionError } from '../model/errors.js'; +import { AccountHeaderService } from './header.js'; + +describe('AccountHeaderService', () => { + const headerRepository = new InMemoryAccountHeaderRepository(); + beforeEach(() => { + headerRepository.reset([testMedium, testNSFWMedium, testOtherMedium]); + }); + + const mediaModule = dummyMediaModuleFacade( + new InMemoryMediaRepository([testMedium, testNSFWMedium, testOtherMedium]), + ); + const service = new AccountHeaderService(headerRepository, mediaModule); + + it('Should set account header image', async () => { + const res = await service.create( + '101' as AccountID, + '300' as MediumID, + '101' as AccountID, + ); + expect(Result.isOk(res)).toBe(true); + }); + + it('should not set account header image if medium is NSFW', async () => { + const res = await service.create( + '101' as AccountID, + '301' as MediumID, + '101' as AccountID, + ); + expect(Result.isErr(res)).toBe(true); + }); + + it('should unset account header image', async () => { + await service.create( + '101' as AccountID, + '300' as MediumID, + '101' as AccountID, + ); + + const res = await service.delete('101' as AccountID, '101' as AccountID); + expect(Result.isOk(res)).toBe(true); + }); + + it("should fetch account's header image", async () => { + await service.create( + '101' as AccountID, + '300' as MediumID, + '101' as AccountID, + ); + + const res = await headerRepository.findByID('101' as AccountID); + expect(Result.isOk(res)).toBe(true); + expect(Result.unwrap(res).getId()).toBe('300'); + }); + + it('set: actor must same as target account', async () => { + const res = await service.create( + '101' as AccountID, + '300' as MediumID, + '1' as AccountID, + ); + expect(Result.isErr(res)).toBe(true); + expect(Result.unwrapErr(res)).toBeInstanceOf( + AccountInsufficientPermissionError, + ); + }); + + it('set: medium author must same as actor', async () => { + const res = await service.create( + '101' as AccountID, + testOtherMedium.getId(), + '101' as AccountID, + ); + expect(Result.isErr(res)).toBe(true); + expect(Result.unwrapErr(res)).toBeInstanceOf( + AccountInsufficientPermissionError, + ); + }); + + it('unset: actor must same as target account', async () => { + const res = await service.delete('101' as AccountID, '1' as AccountID); + expect(Result.isErr(res)).toBe(true); + expect(Result.unwrapErr(res)).toBeInstanceOf( + AccountInsufficientPermissionError, + ); + }); +}); diff --git a/pkg/accounts/service/header.ts b/pkg/accounts/service/header.ts new file mode 100644 index 00000000..0a520692 --- /dev/null +++ b/pkg/accounts/service/header.ts @@ -0,0 +1,152 @@ +import { Ether, Result } from '@mikuroxina/mini-fn'; +import type { Medium, MediumID } from '../../drive/model/medium.js'; +import { + type MediaModuleFacade, + mediaModuleFacadeSymbol, +} from '../../intermodule/media.js'; +import type { AccountID } from '../model/account.js'; +import { AccountInsufficientPermissionError } from '../model/errors.js'; +import { + type AccountHeaderRepository, + accountHeaderRepoSymbol, +} from '../model/repository.js'; + +export class AccountHeaderService { + constructor( + private readonly headerRepository: AccountHeaderRepository, + private readonly mediaModule: MediaModuleFacade, + ) {} + + /** + * @description Set account header image. + * + * header specification: + * - NSFW Media can't be used as header image. + * - Media must be image type(ToDo). + * - Media author must be actor. + * + * @param accountID + * @param mediumID + * @param actorID + */ + async create( + accountID: AccountID, + mediumID: MediumID, + actorID: AccountID, + ): Promise> { + const mediumRes = await this.mediaModule.fetchMedia(mediumID); + if (Result.isErr(mediumRes)) { + return mediumRes; + } + const medium = Result.unwrap(mediumRes); + if (medium.isNsfw()) { + return Result.err( + new AccountInsufficientPermissionError( + "NSFW media can't be used as header image", + { cause: null }, + ), + ); + } + // ToDo: Check media type + + const isAllowedRes = this.isAllowed('set', actorID, { + targetAccount: accountID, + medium, + }); + if (Result.isErr(isAllowedRes)) { + return isAllowedRes; + } + + const res = await this.headerRepository.create(accountID, mediumID); + if (Result.isErr(res)) { + return res; + } + + return Result.ok(undefined); + } + + /** + * @description Unset account header image. + * @param accountID + * @param actorID + */ + async delete( + accountID: AccountID, + actorID: AccountID, + ): Promise> { + const isAllowedRes = this.isAllowed('unset', actorID, { + targetAccount: accountID, + }); + if (Result.isErr(isAllowedRes)) { + return isAllowedRes; + } + + const res = await this.headerRepository.delete(accountID); + if (Result.isErr(res)) { + return res; + } + + return Result.ok(undefined); + } + + /** + * @description Fetch account header image metadata. + * @param accountID + */ + async fetchByAccountID( + accountID: AccountID, + ): Promise> { + return await this.headerRepository.findByID(accountID); + } + + private isAllowed( + action: 'set' | 'unset', + actor: AccountID, + resources: { targetAccount: AccountID; medium?: Medium }, + ): Result.Result { + switch (action) { + case 'set': + // NOTE: actor must be same as target. + if (actor !== resources.targetAccount) { + return Result.err( + new AccountInsufficientPermissionError( + 'Actor must be same as target', + { cause: null }, + ), + ); + } + // NOTE: media author must be actor. + if (resources.medium?.getAuthorId() !== actor) { + return Result.err( + new AccountInsufficientPermissionError( + 'Media author must be actor', + { cause: null }, + ), + ); + } + return Result.ok(undefined); + case 'unset': + // NOTE: actor must be same as target. + if (actor !== resources.targetAccount) { + return Result.err( + new AccountInsufficientPermissionError( + 'Actor must be same as target', + { cause: null }, + ), + ); + } + + return Result.ok(undefined); + } + } +} +export const accountHeaderSymbol = Ether.newEtherSymbol(); +export const accountHeader = Ether.newEther( + accountHeaderSymbol, + ({ mediaModule, headerRepository }) => + new AccountHeaderService(headerRepository, mediaModule), + { + mediaModule: mediaModuleFacadeSymbol, + headerRepository: accountHeaderRepoSymbol, + }, +); diff --git a/pkg/accounts/service/register.test.ts b/pkg/accounts/service/register.test.ts index dc93a789..0286947e 100644 --- a/pkg/accounts/service/register.test.ts +++ b/pkg/accounts/service/register.test.ts @@ -3,10 +3,8 @@ import { describe, expect, it } from 'vitest'; import { MockClock, SnowflakeIDGenerator } from '../../id/mod.js'; import { Argon2idPasswordEncoder } from '../../password/mod.js'; -import { - InMemoryAccountRepository, - InMemoryAccountVerifyTokenRepository, -} from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; +import { InMemoryAccountVerifyTokenRepository } from '../adaptor/repository/dummy/verifyToken.js'; import type { AccountName, AccountRole } from '../model/account.js'; import { RegisterService } from './register.js'; import { DummySendNotificationService } from './sendNotification.js'; diff --git a/pkg/accounts/service/resendToken.test.ts b/pkg/accounts/service/resendToken.test.ts index f72f11c8..695f995c 100644 --- a/pkg/accounts/service/resendToken.test.ts +++ b/pkg/accounts/service/resendToken.test.ts @@ -2,10 +2,8 @@ import { Option } from '@mikuroxina/mini-fn'; import { afterEach, describe, expect, it } from 'vitest'; import { MockClock } from '../../id/mod.js'; -import { - InMemoryAccountRepository, - InMemoryAccountVerifyTokenRepository, -} from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; +import { InMemoryAccountVerifyTokenRepository } from '../adaptor/repository/dummy/verifyToken.js'; import { Account, type AccountID } from '../model/account.js'; import { AccountNotFoundError } from '../model/errors.js'; import { ResendVerifyTokenService } from './resendToken.js'; diff --git a/pkg/accounts/service/silence.test.ts b/pkg/accounts/service/silence.test.ts index d2309953..168f5f1b 100644 --- a/pkg/accounts/service/silence.test.ts +++ b/pkg/accounts/service/silence.test.ts @@ -1,7 +1,7 @@ import { Option } from '@mikuroxina/mini-fn'; import { afterEach, describe, expect, it } from 'vitest'; -import { InMemoryAccountRepository } from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; import { Account, type AccountID } from '../model/account.js'; import { SilenceService } from './silence.js'; diff --git a/pkg/accounts/service/unfollow.test.ts b/pkg/accounts/service/unfollow.test.ts index 0c247769..8ee0609b 100644 --- a/pkg/accounts/service/unfollow.test.ts +++ b/pkg/accounts/service/unfollow.test.ts @@ -1,10 +1,8 @@ import { Option } from '@mikuroxina/mini-fn'; import { describe, expect, it } from 'vitest'; -import { - InMemoryAccountFollowRepository, - InMemoryAccountRepository, -} from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; +import { InMemoryAccountFollowRepository } from '../adaptor/repository/dummy/follow.js'; import { Account, type AccountID } from '../model/account.js'; import { AccountFollow } from '../model/follow.js'; import { UnfollowService } from './unfollow.js'; diff --git a/pkg/accounts/service/verifyToken.test.ts b/pkg/accounts/service/verifyToken.test.ts index 0bc0293f..3842badb 100644 --- a/pkg/accounts/service/verifyToken.test.ts +++ b/pkg/accounts/service/verifyToken.test.ts @@ -2,10 +2,8 @@ import { Result } from '@mikuroxina/mini-fn'; import { describe, expect, it } from 'vitest'; import { MockClock } from '../../id/mod.js'; -import { - InMemoryAccountRepository, - InMemoryAccountVerifyTokenRepository, -} from '../adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../adaptor/repository/dummy/account.js'; +import { InMemoryAccountVerifyTokenRepository } from '../adaptor/repository/dummy/verifyToken.js'; import { Account, type AccountID } from '../model/account.js'; import { VerifyAccountTokenService } from './verifyToken.js'; diff --git a/pkg/drive/service/fetch.ts b/pkg/drive/service/fetch.ts index 8c45bb51..1d90cf9b 100644 --- a/pkg/drive/service/fetch.ts +++ b/pkg/drive/service/fetch.ts @@ -2,7 +2,7 @@ import { Ether, Option, type Result } from '@mikuroxina/mini-fn'; import type { AccountID } from '../../accounts/model/account.js'; import { MediaNotFoundError } from '../model/errors.js'; -import type { Medium } from '../model/medium.js'; +import type { Medium, MediumID } from '../model/medium.js'; import { type MediaRepository, mediaRepoSymbol } from '../model/repository.js'; export class FetchMediaService { @@ -16,6 +16,15 @@ export class FetchMediaService { () => new MediaNotFoundError('Failed to fetch media', { cause: null }), )(res); } + + async fetchMediaByID( + mediumID: MediumID, + ): Promise> { + const res = await this.mediaRepository.findById(mediumID); + return Option.okOrElse( + () => new MediaNotFoundError('Failed to fetch media', { cause: null }), + )(res); + } } export const fetchMediaServiceSymbol = Ether.newEtherSymbol(); diff --git a/pkg/drive/testData/testData.ts b/pkg/drive/testData/testData.ts index 6901bce1..23647558 100644 --- a/pkg/drive/testData/testData.ts +++ b/pkg/drive/testData/testData.ts @@ -22,3 +22,14 @@ export const testNSFWMedium = Medium.new({ thumbnailUrl: 'https://example.com/test_thumbnail.jpg', hash: '40kdflnrh', }); + +export const testOtherMedium = Medium.new({ + id: '303' as MediumID, + name: 'test.jpg', + mime: 'image/jpeg', + authorId: '102' as AccountID, + nsfw: false, + url: 'https://example.com/test.jpg', + thumbnailUrl: 'https://example.com/test_thumbnail.jpg', + hash: '40kdflnrh', +}); diff --git a/pkg/intermodule/account.ts b/pkg/intermodule/account.ts index 7f33addc..d6277e51 100644 --- a/pkg/intermodule/account.ts +++ b/pkg/intermodule/account.ts @@ -1,12 +1,10 @@ import { Cat, Ether, Result } from '@mikuroxina/mini-fn'; -import { - InMemoryAccountRepository, - newFollowRepo, -} from '../accounts/adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../accounts/adaptor/repository/dummy/account.js'; +import { newFollowRepo } from '../accounts/adaptor/repository/dummy/follow.js'; import { PrismaAccountRepository, prismaFollowRepo, -} from '../accounts/adaptor/repository/prisma.js'; +} from '../accounts/adaptor/repository/prisma/prisma.js'; import type { Account, AccountID, diff --git a/pkg/intermodule/media.ts b/pkg/intermodule/media.ts new file mode 100644 index 00000000..aad87e9b --- /dev/null +++ b/pkg/intermodule/media.ts @@ -0,0 +1,44 @@ +import { Ether, type Result } from '@mikuroxina/mini-fn'; +import { isProduction } from '../adaptors/env.js'; +import { prismaClient } from '../adaptors/prisma.js'; +import { InMemoryMediaRepository } from '../drive/adaptor/repository/dummy.js'; +import { PrismaMediaRepository } from '../drive/adaptor/repository/prisma.js'; +import type { Medium, MediumID } from '../drive/model/medium.js'; +import { FetchMediaService } from '../drive/service/fetch.js'; + +/** + * Media Module facade. + */ +export class MediaModuleFacade { + constructor(private readonly fetchMediaService: FetchMediaService) {} + + async fetchMedia(mediumID: MediumID): Promise> { + return await this.fetchMediaService.fetchMediaByID(mediumID); + } +} +export const mediaModuleFacadeSymbol = + Ether.newEtherSymbol(); +export const mediaModuleFacadeEther = Ether.newEther( + mediaModuleFacadeSymbol, + () => mediaModuleFacade, +); + +/** + * Media module facade object for dependency injection. + */ +export const mediaModuleFacade = new MediaModuleFacade( + new FetchMediaService( + isProduction + ? new PrismaMediaRepository(prismaClient) + : new InMemoryMediaRepository([]), + ), +); + +/** + * Dummy media module.\ + * **NOTE: MUST USE THIS OBJECT FOR TESTING ONLY** + * @param mediaRepository Dummy media repository + */ +export const dummyMediaModuleFacade = ( + mediaRepository: InMemoryMediaRepository, +) => new MediaModuleFacade(new FetchMediaService(mediaRepository)); diff --git a/pkg/notes/service/fetch.test.ts b/pkg/notes/service/fetch.test.ts index 64dd9b37..36b22eda 100644 --- a/pkg/notes/service/fetch.test.ts +++ b/pkg/notes/service/fetch.test.ts @@ -1,7 +1,7 @@ import { Option, Result } from '@mikuroxina/mini-fn'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { InMemoryAccountRepository } from '../../accounts/adaptor/repository/dummy.js'; +import { InMemoryAccountRepository } from '../../accounts/adaptor/repository/dummy/account.js'; import { Account, type AccountID } from '../../accounts/model/account.js'; import type { MediumID } from '../../drive/model/medium.js'; import { testMedium, testNSFWMedium } from '../../drive/testData/testData.js'; diff --git a/pkg/timeline/service/push.ts b/pkg/timeline/service/push.ts index e9a18856..a87a4d6b 100644 --- a/pkg/timeline/service/push.ts +++ b/pkg/timeline/service/push.ts @@ -45,7 +45,6 @@ export class PushTimelineService { const timeline = Result.unwrap(timelineRes); if (timeline.length >= this.TIMELINE_CACHE_LIMIT) { const oldNotes = timeline.slice(this.TIMELINE_CACHE_LIMIT - 1); - console.log(timeline.length, this.TIMELINE_CACHE_LIMIT); return this.timelineNotesCacheRepository.deleteNotesFromHomeTimeline( timelineID as AccountID, oldNotes, diff --git a/prisma/migrations/20241108150155_add_account_header_and_avatar/migration.sql b/prisma/migrations/20241108150155_add_account_header_and_avatar/migration.sql new file mode 100644 index 00000000..441532c8 --- /dev/null +++ b/prisma/migrations/20241108150155_add_account_header_and_avatar/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "account_avatar" ( + "account_id" TEXT NOT NULL, + "medium_id" TEXT NOT NULL, + + CONSTRAINT "account_avatar_pkey" PRIMARY KEY ("account_id","medium_id") +); + +-- CreateTable +CREATE TABLE "account_header" ( + "account_id" TEXT NOT NULL, + "medium_id" TEXT NOT NULL, + + CONSTRAINT "account_header_pkey" PRIMARY KEY ("account_id","medium_id") +); + +-- AddForeignKey +ALTER TABLE "account_avatar" ADD CONSTRAINT "account_avatar_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "account_avatar" ADD CONSTRAINT "account_avatar_medium_id_fkey" FOREIGN KEY ("medium_id") REFERENCES "medium"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "account_header" ADD CONSTRAINT "account_header_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "account_header" ADD CONSTRAINT "account_header_medium_id_fkey" FOREIGN KEY ("medium_id") REFERENCES "medium"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20241112132905_add_unique_to_header_accountid/migration.sql b/prisma/migrations/20241112132905_add_unique_to_header_accountid/migration.sql new file mode 100644 index 00000000..d7e5da19 --- /dev/null +++ b/prisma/migrations/20241112132905_add_unique_to_header_accountid/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[account_id]` on the table `account_avatar` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[account_id]` on the table `account_header` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "account_avatar_account_id_key" ON "account_avatar"("account_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "account_header_account_id_key" ON "account_header"("account_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 419f01de..ed07a78e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,8 @@ model Account { following Following[] @relation("following") followed Following[] @relation("followed") + avatar AccountAvatar[] @relation("avatar") + header AccountHeader[] @relation("header") note Note[] reaction Reaction[] bookmark Bookmark[] @@ -140,10 +142,32 @@ model Medium { createdAt DateTime @default(now()) @map("created_at") deletedAt DateTime? @map("deleted_at") noteAttachment NoteAttachment[] + AccountAvatar AccountAvatar[] + AccountHeader AccountHeader[] @@map("medium") } +model AccountAvatar { + accountId String @unique @map("account_id") + account Account @relation("avatar", fields: [accountId], references: [id]) + mediumId String @map("medium_id") + medium Medium @relation(fields: [mediumId], references: [id]) + + @@id([accountId, mediumId]) + @@map("account_avatar") +} + +model AccountHeader { + accountId String @unique @map("account_id") + account Account @relation("header", fields: [accountId], references: [id]) + mediumId String @map("medium_id") + medium Medium @relation(fields: [mediumId], references: [id]) + + @@id([accountId, mediumId]) + @@map("account_header") +} + model NoteAttachment { mediumId String @map("medium_id") medium Medium @relation(fields: [mediumId], references: [id])