diff --git a/src/domain/user.ts b/src/domain/user.ts index 241b103..bb38c1b 100644 --- a/src/domain/user.ts +++ b/src/domain/user.ts @@ -248,10 +248,6 @@ export class UserAPData { } export class UserFollowEvent { - // ToDo: - // DTOなどへの変換時に再帰的に変換が行われる可能性がある - // ->必要なデータ: id,nickName, fullHandle, iconImageURL, bio - // フォローされたユーザー(dst) private readonly _follower: FollowUser; // フォローしたユーザー(from) diff --git a/src/repository/inmemory/user.ts b/src/repository/inmemory/user.ts index 6fbc63d..2d07514 100644 --- a/src/repository/inmemory/user.ts +++ b/src/repository/inmemory/user.ts @@ -1,14 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { IUserRepository } from "../user.js"; import { AsyncResult, Failure, Result, Success } from "../../helpers/result.js"; -import { User } from "../../domain/user.js"; +import { User, UserFollowEvent } from "../../domain/user.js"; import { Snowflake } from "../../helpers/id_generator.js"; export class UserRepository implements IUserRepository { private data: Set; + private followData: Set; - constructor(data: User[]) { + constructor(data: User[], followData?: UserFollowEvent[]) { this.data = new Set(data); + this.followData = new Set(followData); } async Create(u: User): Promise> { @@ -66,4 +68,25 @@ export class UserRepository implements IUserRepository { ); } } + + async CreateFollow(u: UserFollowEvent): AsyncResult { + try { + this.followData.add(u); + return new Success(u); + } catch (e: unknown) { + return new Failure(new Error(e as any)); + } + } + + async FindFollowEvent( + followingID: Snowflake, + followerID: Snowflake, + ): AsyncResult { + for (const v of [...this.followData]) { + if (v.follower.id === followerID && v.following.id === followingID) { + return new Success(v); + } + } + return new Failure(new Error("failed to find follow event")); + } } diff --git a/src/repository/prisma/user.ts b/src/repository/prisma/user.ts index 4fced51..cc3d881 100644 --- a/src/repository/prisma/user.ts +++ b/src/repository/prisma/user.ts @@ -58,6 +58,33 @@ export class UserRepository implements IUserRepository { } } + async CreateFollow(u: UserFollowEvent): AsyncResult { + try { + const res = await this.prisma.userFollowEvent.create({ + data: { + follower: { + connect: { + id: u.follower.id, + }, + }, + following: { + connect: { + id: u.following.id, + }, + }, + }, + include: { + follower: true, + following: true, + }, + }); + const resp: UserFollowEvent = this.convertToFollowEventDomain(res); + return new Success(resp); + } catch (e: unknown) { + return new Failure(PrismaErrorConverter(e)); + } + } + async FindByHandle(handle: string): Promise> { try { const res = await this.prisma.user.findUniqueOrThrow({ @@ -247,6 +274,38 @@ export class UserRepository implements IUserRepository { }); }); } + + private convertToFollowEventDomain( + i: T, + ): UserFollowEvent { + return new UserFollowEvent( + { ...i.following, id: i.following.id as Snowflake }, + { ...i.follower, id: i.follower.id as Snowflake }, + ); + } + + async FindFollowEvent( + followingID: Snowflake, + followerID: Snowflake, + ): AsyncResult { + try { + const res = await this.prisma.userFollowEvent.findUniqueOrThrow({ + where: { + followingID_followerID: { + followingID: followingID, + followerID: followerID, + }, + }, + include: { + follower: true, + following: true, + }, + }); + return new Success(this.convertToFollowEventDomain(res)); + } catch (e: unknown) { + return new Failure(PrismaErrorConverter(e)); + } + } } export type UserEntity = { @@ -288,3 +347,20 @@ export type UserEntity = { id: string; }; }; + +export type UserFollowEventEntity = { + follower: { + id: string; + fullHandle: string; + nickName: string; + bio: string; + iconImageURL: string; + }; + following: { + id: string; + fullHandle: string; + nickName: string; + bio: string; + iconImageURL: string; + }; +}; diff --git a/src/repository/user.ts b/src/repository/user.ts index ce5addd..de38fb9 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -1,13 +1,18 @@ -import { User } from "../domain/user.js"; +import { User, UserFollowEvent } from "../domain/user.js"; import { AsyncResult } from "../helpers/result.js"; import { Snowflake } from "../helpers/id_generator.js"; export interface IUserRepository { Create(u: User): AsyncResult; Update(u: User): AsyncResult; + CreateFollow(u: UserFollowEvent): AsyncResult; FindByID(id: Snowflake): AsyncResult; FindByHandle(handle: string): AsyncResult; FindFollowing(id: Snowflake): AsyncResult, Error>; FindFollower(id: Snowflake): AsyncResult, Error>; + FindFollowEvent( + followingID: Snowflake, + followerID: Snowflake, + ): AsyncResult; } diff --git a/src/server/controller/user.ts b/src/server/controller/user.ts index f07c44c..5b1bef4 100644 --- a/src/server/controller/user.ts +++ b/src/server/controller/user.ts @@ -1,6 +1,10 @@ import { FindUserService } from "../../service/user/find_user_service.js"; import { AsyncResult, Failure, Result, Success } from "../../helpers/result.js"; -import { UserResponse } from "../types/user.js"; +import { + DomainToUserFollowResponse, + UserFollowResponse, + UserResponse, +} from "../types/user.js"; import { FindServerService } from "../../service/server/find_server_service.js"; import { FindPostService } from "../../service/post/find_post_service.js"; import { @@ -10,20 +14,24 @@ import { } from "../types/post.js"; import { Snowflake } from "../../helpers/id_generator.js"; import { PostData } from "../../service/data/post.js"; +import { CreateFollowService } from "../../service/user/create_follow_service.js"; export class UserController { private readonly findUserService: FindUserService; private readonly findServerService: FindServerService; private readonly findPostService: FindPostService; + private readonly createFollowService: CreateFollowService; constructor(args: { findUserService: FindUserService; findServerService: FindServerService; findPostService: FindPostService; + createFollowService: CreateFollowService; }) { this.findUserService = args.findUserService; this.findServerService = args.findServerService; this.findPostService = args.findPostService; + this.createFollowService = args.createFollowService; } async FindByHandle(name: string): AsyncResult { @@ -120,6 +128,20 @@ export class UserController { ); } + async CreateFollow( + followerID: string, + followingID: string, + ): AsyncResult { + const res = await this.createFollowService.Handle( + followingID as Snowflake, + followerID as Snowflake, + ); + if (res.isFailure()) { + return new Failure(res.value); + } + return new Success(DomainToUserFollowResponse(res.value)); + } + private acctConverter(acct: string): Result { const split = acct.split("@"); switch (split.length) { diff --git a/src/server/handlers/user.ts b/src/server/handlers/user.ts index ccd56ea..c26d440 100644 --- a/src/server/handlers/user.ts +++ b/src/server/handlers/user.ts @@ -29,4 +29,16 @@ export class UserHandlers { r.code(200).send(res.value); return; }; + + public CreateFollow: FastifyHandlerMethod<{ Params: { id: string } }> = + async (q, r) => { + // APIを叩いたユーザー - フォロー > フォロー先(params.id) + const res = await this.controller.CreateFollow(q.params.id, "123"); + if (res.isFailure()) { + const [code, message] = ErrorConverter(res.value); + return r.code(code).send(message); + } + + r.code(200).send(res.value); + }; } diff --git a/src/server/start.ts b/src/server/start.ts index 8890cfd..4c5c1b0 100644 --- a/src/server/start.ts +++ b/src/server/start.ts @@ -26,6 +26,7 @@ import { NodeInfoController } from "./controller/activitypub/nodeinfo.js"; import { PersonHandler } from "./handlers/activitypub/person.js"; import { PersonController } from "./controller/activitypub/person.js"; import logger from "../helpers/logger.js"; +import { CreateFollowService } from "../service/user/create_follow_service.js"; export async function StartServer(port: number) { const app = fastify({ @@ -71,6 +72,7 @@ export async function StartServer(port: number) { findServerService: new FindServerService(serverRepository), findUserService: new FindUserService(userRepository), findPostService: new FindPostService(postRepository), + createFollowService: new CreateFollowService(userRepository), }), ); const apHandler = new WebFingerHandler( @@ -90,6 +92,7 @@ export async function StartServer(port: number) { app.delete("/api/v1/posts/:id/reaction", postHandler.UndoReaction); app.post("/api/v1/posts", postHandler.CreatePost); app.get("/api/v1/users/:name", userHandler.FindByHandle); + app.post("/api/v1/users/:id/follow", userHandler.CreateFollow); app.get("/api/v1/users/:name/posts", userHandler.FindUserPosts); app.get("/api/v1/timeline/home", postHandler.GetTimeline); diff --git a/src/server/types/user.ts b/src/server/types/user.ts index fba5073..c16732c 100644 --- a/src/server/types/user.ts +++ b/src/server/types/user.ts @@ -1,3 +1,5 @@ +import { UserFollowEventData } from "../../service/data/user.js"; + export interface UserResponse { id: string; host: string; @@ -9,3 +11,41 @@ export interface UserResponse { following: Array; softwareName: string; } + +export interface UserFollowResponse { + following: { + id: string; + nickName: string; + fullHandle: string; + iconImageURL: string; + bio: string; + }; + follower: { + id: string; + nickName: string; + fullHandle: string; + iconImageURL: string; + bio: string; + }; +} + +export function DomainToUserFollowResponse( + d: UserFollowEventData, +): UserFollowResponse { + return { + following: { + id: d.following.id, + nickName: d.following.nickName, + fullHandle: d.following.fullHandle, + iconImageURL: d.following.iconImageURL, + bio: d.following.bio, + }, + follower: { + id: d.follower.id, + nickName: d.follower.nickName, + fullHandle: d.follower.fullHandle, + iconImageURL: d.follower.iconImageURL, + bio: d.follower.bio, + }, + }; +} diff --git a/src/service/data/user.ts b/src/service/data/user.ts index 88553cd..e17ea49 100644 --- a/src/service/data/user.ts +++ b/src/service/data/user.ts @@ -305,6 +305,12 @@ export class UserFollowEventData { } } +export function UserFollowEventToUserFollowEventData( + u: UserFollowEvent, +): UserFollowEventData { + return new UserFollowEventData(u.following, u.follower); +} + export interface FollowUserData { id: Snowflake; nickName: string; diff --git a/src/service/user/create_follow_service.test.ts b/src/service/user/create_follow_service.test.ts new file mode 100644 index 0000000..10afbe5 --- /dev/null +++ b/src/service/user/create_follow_service.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { CreateFollowService } from "./create_follow_service.js"; +import { UserRepository } from "../../repository/inmemory/user.js"; +import { User, UserAPData } from "../../domain/user.js"; +import { Snowflake } from "../../helpers/id_generator.js"; + +describe("create_follow_service", () => { + const repository = new UserRepository([ + new User({ + id: "1" as Snowflake, + fullHandle: "testuser@example.com", + password: "", + role: 0, + nickName: "test", + handle: "test", + bio: "test", + headerImageURL: "test", + iconImageURL: "test", + isLocalUser: true, + serverID: "1" as Snowflake, + apData: new UserAPData({ + userID: "1" as Snowflake, + userAPID: "2" as Snowflake, + inboxURL: "test", + outboxURL: "test", + followersURL: "https://example.com", + followingURL: "https://example.com", + publicKey: "test", + privateKey: null, + }), + following: [], + createdAt: new Date(), + }), + new User({ + id: "2" as Snowflake, + fullHandle: "testuser2@example.com", + password: "", + role: 0, + nickName: "test", + handle: "test", + bio: "test", + headerImageURL: "test", + iconImageURL: "test", + isLocalUser: true, + serverID: "1" as Snowflake, + apData: new UserAPData({ + userID: "2" as Snowflake, + userAPID: "3" as Snowflake, + inboxURL: "test", + outboxURL: "test", + followersURL: "https://example.com", + followingURL: "https://example.com", + publicKey: "test", + privateKey: null, + }), + following: [], + createdAt: new Date(), + }), + ]); + const service = new CreateFollowService(repository); + + it("フォローできる", async () => { + const res = await service.Handle("1" as Snowflake, "2" as Snowflake); + expect(res.value).not.toBeUndefined(); + expect(res.isFailure()).toBe(false); + }); + + it("すでにフォローしている相手はフォローできない", async () => { + await service.Handle("1" as Snowflake, "2" as Snowflake); + const res2 = await service.Handle("1" as Snowflake, "2" as Snowflake); + expect(res2.isFailure()).toBe(true); + }); +}); diff --git a/src/service/user/create_follow_service.ts b/src/service/user/create_follow_service.ts new file mode 100644 index 0000000..8e6f12f --- /dev/null +++ b/src/service/user/create_follow_service.ts @@ -0,0 +1,45 @@ +import { Failure, Success } from "../../helpers/result.js"; +import { IUserRepository } from "../../repository/user.js"; +import { UserFollowEventToUserFollowEventData } from "../data/user.js"; +import { Snowflake } from "../../helpers/id_generator.js"; +import { UserFollowEvent } from "../../domain/user.js"; + +export class CreateFollowService { + private readonly repository: IUserRepository; + + constructor(repository: IUserRepository) { + this.repository = repository; + } + + // Following - フォロー > Follower + async Handle(followingID: Snowflake, followerID: Snowflake) { + // すでにフォローしている場合は処理を切る + const isExists = await this.isExists(followingID, followerID); + if (isExists) { + return new Failure(new Error("already following")); + } + + // ユーザーを取ってくる + const following = await this.repository.FindByID(followingID); + if (following.isFailure()) { + return new Failure(new Error("failed to find user", following.value)); + } + const follower = await this.repository.FindByID(followerID); + if (follower.isFailure()) { + return new Failure(new Error("failed to find user", follower.value)); + } + + const req = new UserFollowEvent(following.value, follower.value); + const res = await this.repository.CreateFollow(req); + if (res.isFailure()) { + return new Failure(new Error("failed to create follow", res.value)); + } + + return new Success(UserFollowEventToUserFollowEventData(res.value)); + } + + private async isExists(followingID: Snowflake, followerID: Snowflake) { + const obj = await this.repository.FindFollowEvent(followingID, followerID); + return !obj.isFailure(); + } +}