diff --git a/server/data.ts b/server/data.ts index 63381ea..49f7cf8 100644 --- a/server/data.ts +++ b/server/data.ts @@ -8,6 +8,7 @@ export const users: DbUser[] = [ handle: "darklord", avatar: "/cdn/avatars/darklord.jpeg", info: "I am the dark lord, the root of all evil. 'Tis I who brought the world to its knees. In blood I was born, and in blood I shall have my vengeance.", + numShoutsPastDay: 3, blockedUserIds: ["user-2"], followsUserIds: ["user-3"], }, @@ -17,6 +18,7 @@ export const users: DbUser[] = [ type: "user", attributes: { handle: "prettypinkpony", + numShoutsPastDay: 4, avatar: "/cdn/avatars/prettypinkpony.jpeg", info: "I like colors. I'm a colorful person (although I'm pretty white *giggles*). I'd like to make this world a better place. And sometimes I feel like the only one who can...", blockedUserIds: ["user-1"], @@ -29,6 +31,7 @@ export const users: DbUser[] = [ attributes: { handle: "fcku", avatar: "/cdn/avatars/fcku.jpeg", + numShoutsPastDay: 2, blockedUserIds: [], followsUserIds: ["user-1", "user-2"], }, diff --git a/server/index.ts b/server/index.ts index 8bfd037..5348fa1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -237,10 +237,16 @@ fastify.post( reply ) { await waitRandomTime(); + + if (req.body.message === "error") { + return reply.status(400).send({ error: "unkown error" }); + } + const user = getUserFromCookie(req.cookies); if (!user) { return reply.status(401).send({ error: true }); } + user.attributes.numShoutsPastDay += 1; const shout: DbShout = { id: `shout-${shouts.length + 1}`, type: "shout", diff --git a/server/types.ts b/server/types.ts index e8e4aaf..89c6702 100644 --- a/server/types.ts +++ b/server/types.ts @@ -9,6 +9,7 @@ export interface DbUser { handle: string; avatar: string; info?: string; + numShoutsPastDay: number; blockedUserIds: UserId[]; followsUserIds: UserId[]; }; diff --git a/src/application/get-user-profile/get-user-profile.test.ts b/src/application/get-user-profile/get-user-profile.test.ts new file mode 100644 index 0000000..24c8507 --- /dev/null +++ b/src/application/get-user-profile/get-user-profile.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vitest } from "vitest"; + +import { getUserProfile } from "./get-user-profile"; + +const handle = "user-handle"; + +const mockGetUser = vitest.fn().mockResolvedValue({ + id: "user-id", + handle: "user-handle", + avatar: "user-avatar", + info: "user-info", + followerIds: [], +}); + +const mockGetUserShouts = vitest.fn().mockResolvedValue({ + shouts: [ + { + id: "shout-id", + createdAt: 1234567890, + authorId: "user-id", + text: "shout-text", + likes: 0, + reshouts: 0, + imageId: "image-id", + replies: [], + }, + ], + images: [ + { + id: "image-id", + url: "image-url", + }, + ], +}); + +const mockDependencies = { + getUser: mockGetUser, + getUserShouts: mockGetUserShouts, +}; + +describe("getUserProfile", () => { + it("should create a new shout with an image and reply to it", async () => { + const result = await getUserProfile({ handle }, mockDependencies); + + expect(mockGetUser).toHaveBeenCalledWith(handle); + expect(mockGetUserShouts).toHaveBeenCalledWith(handle); + + expect(result).toEqual({ + user: { + id: "user-id", + handle: "user-handle", + avatar: "user-avatar", + info: "user-info", + followerIds: [], + }, + shouts: [ + { + id: "shout-id", + createdAt: 1234567890, + authorId: "user-id", + text: "shout-text", + likes: 0, + reshouts: 0, + imageId: "image-id", + replies: [], + }, + ], + images: [ + { + id: "image-id", + url: "image-url", + }, + ], + }); + }); +}); diff --git a/src/application/get-user-profile/get-user-profile.ts b/src/application/get-user-profile/get-user-profile.ts new file mode 100644 index 0000000..56ae8f7 --- /dev/null +++ b/src/application/get-user-profile/get-user-profile.ts @@ -0,0 +1,30 @@ +import { useCallback } from "react"; + +import UserService from "@/infrastructure/user"; + +interface GetUserProfileInput { + handle: string; +} + +const dependencies = { + getUser: UserService.getUser, + getUserShouts: UserService.getUserShouts, +}; + +export async function getUserProfile( + { handle }: GetUserProfileInput, + { getUser, getUserShouts }: typeof dependencies +) { + const [user, { shouts, images }] = await Promise.all([ + getUser(handle), + getUserShouts(handle), + ]); + return { user, shouts, images }; +} + +export function useGetUserProfile() { + return useCallback( + (input: GetUserProfileInput) => getUserProfile(input, dependencies), + [] + ); +} diff --git a/src/application/get-user-profile/index.ts b/src/application/get-user-profile/index.ts new file mode 100644 index 0000000..2c5f7ef --- /dev/null +++ b/src/application/get-user-profile/index.ts @@ -0,0 +1 @@ +export { useGetUserProfile } from "./get-user-profile"; diff --git a/src/application/reply-to-shout/index.ts b/src/application/reply-to-shout/index.ts new file mode 100644 index 0000000..eac6a9d --- /dev/null +++ b/src/application/reply-to-shout/index.ts @@ -0,0 +1 @@ +export { useReplyToShout } from "./reply-to-shout"; diff --git a/src/application/reply-to-shout/reply-to-shout.test.ts b/src/application/reply-to-shout/reply-to-shout.test.ts new file mode 100644 index 0000000..fb94a3f --- /dev/null +++ b/src/application/reply-to-shout/reply-to-shout.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vitest } from "vitest"; + +import { createMockFile } from "@/test/create-mock-file"; + +import { ErrorMessages, replyToShout } from "./reply-to-shout"; + +const recipientHandle = "recipient-handle"; +const shoutId = "shout-id"; +const message = "Hello, world!"; +const files = [createMockFile("image.png")]; + +const imageId = "image-id"; +const newShoutId = "new-shout-id"; + +const mockMe = { + id: "user-1", + handle: "me", + avatar: "user-1.png", + numShoutsPastDay: 0, + blockedUserIds: [], + followerIds: [], +}; + +const mockRecipient = { + id: "user-2", + handle: recipientHandle, + avatar: "user-2.png", + numShoutsPastDay: 0, + blockedUserIds: [], + followerIds: [], +}; + +const mockGetMe = vitest.fn().mockResolvedValue(mockMe); +const mockGetUser = vitest.fn().mockResolvedValue(mockRecipient); +const mockSaveImage = vitest.fn().mockResolvedValue({ id: imageId }); +const mockCreateShout = vitest.fn().mockResolvedValue({ id: newShoutId }); +const mockCreateReply = vitest.fn(); + +const mockDependencies = { + getMe: mockGetMe, + getUser: mockGetUser, + saveImage: mockSaveImage, + createShout: mockCreateShout, + createReply: mockCreateReply, +}; + +describe("replyToShout", () => { + beforeEach(() => { + Object.values(mockDependencies).forEach((mock) => mock.mockClear()); + }); + + it("should return an error if the user has made too many shouts", async () => { + mockGetMe.mockResolvedValueOnce({ ...mockMe, numShoutsPastDay: 5 }); + + const result = await replyToShout( + { recipientHandle, shoutId, message, files }, + mockDependencies + ); + + expect(result).toEqual({ error: ErrorMessages.TooManyShouts }); + }); + + it("should return an error if the recipient does not exist", async () => { + mockGetUser.mockResolvedValueOnce(undefined); + + const result = await replyToShout( + { recipientHandle, shoutId, message, files }, + mockDependencies + ); + + expect(result).toEqual({ error: ErrorMessages.RecipientNotFound }); + }); + + it("should return an error if the recipient has blocked the author", async () => { + mockGetUser.mockResolvedValueOnce({ + ...mockRecipient, + blockedUserIds: [mockMe.id], + }); + + const result = await replyToShout( + { recipientHandle, shoutId, message, files }, + mockDependencies + ); + + expect(result).toEqual({ error: ErrorMessages.AuthorBlockedByRecipient }); + }); + + it("should create a new shout with an image and reply to it", async () => { + await replyToShout( + { recipientHandle, shoutId, message, files }, + mockDependencies + ); + + expect(mockSaveImage).toHaveBeenCalledWith(files[0]); + expect(mockCreateShout).toHaveBeenCalledWith({ + message, + imageId, + }); + expect(mockCreateReply).toHaveBeenCalledWith({ + shoutId, + replyId: newShoutId, + }); + }); + + it("should create a new shout without an image and reply to it", async () => { + await replyToShout( + { recipientHandle, shoutId, message, files: [] }, + mockDependencies + ); + + expect(mockSaveImage).not.toHaveBeenCalled(); + expect(mockCreateShout).toHaveBeenCalledWith({ + message, + imageId: undefined, + }); + expect(mockCreateReply).toHaveBeenCalledWith({ + shoutId, + replyId: newShoutId, + }); + }); +}); diff --git a/src/application/reply-to-shout/reply-to-shout.ts b/src/application/reply-to-shout/reply-to-shout.ts new file mode 100644 index 0000000..91e655f --- /dev/null +++ b/src/application/reply-to-shout/reply-to-shout.ts @@ -0,0 +1,75 @@ +import { useCallback } from "react"; + +import MediaService from "@/infrastructure/media"; +import ShoutService from "@/infrastructure/shout"; +import UserService from "@/infrastructure/user"; + +interface ReplyToShoutInput { + recipientHandle: string; + shoutId: string; + message: string; + files?: File[] | null; +} + +export const ErrorMessages = { + TooManyShouts: + "You have reached the maximum number of shouts per day. Please try again tomorrow.", + RecipientNotFound: "The user you want to reply to does not exist.", + AuthorBlockedByRecipient: + "You can't reply to this user. They have blocked you.", + UnknownError: "An unknown error occurred. Please try again later.", +} as const; + +const dependencies = { + getMe: UserService.getMe, + getUser: UserService.getUser, + saveImage: MediaService.saveImage, + createShout: ShoutService.createShout, + createReply: ShoutService.createReply, +}; + +export async function replyToShout( + { recipientHandle, shoutId, message, files }: ReplyToShoutInput, + { getMe, getUser, saveImage, createReply, createShout }: typeof dependencies +) { + const me = await getMe(); + if (me.numShoutsPastDay >= 5) { + return { error: ErrorMessages.TooManyShouts }; + } + + const recipient = await getUser(recipientHandle); + if (!recipient) { + return { error: ErrorMessages.RecipientNotFound }; + } + if (recipient.blockedUserIds.includes(me.id)) { + return { error: ErrorMessages.AuthorBlockedByRecipient }; + } + + try { + let image; + if (files?.length) { + image = await saveImage(files[0]); + } + + const newShout = await createShout({ + message, + imageId: image?.id, + }); + + await createReply({ + shoutId, + replyId: newShout.id, + }); + + return { error: undefined }; + } catch { + return { error: ErrorMessages.UnknownError }; + } +} + +export function useReplyToShout() { + return useCallback( + (input: ReplyToShoutInput) => replyToShout(input, dependencies), + [] + ); +} diff --git a/src/components/shout/reply-dialog.tsx b/src/components/shout/reply-dialog.tsx index 4d69ba5..cfe3829 100644 --- a/src/components/shout/reply-dialog.tsx +++ b/src/components/shout/reply-dialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; +import { useReplyToShout } from "@/application/reply-to-shout"; import { LoginDialog } from "@/components/login-dialog"; import { Button } from "@/components/ui/button"; import { @@ -14,8 +15,6 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import MediaService from "@/infrastructure/media"; -import ShoutService from "@/infrastructure/shout"; import UserService from "@/infrastructure/user"; interface ReplyFormElements extends HTMLFormControlsCollection { @@ -28,15 +27,22 @@ interface ReplyForm extends HTMLFormElement { } interface ReplyDialogProps { + recipientHandle: string; children: React.ReactNode; shoutId: string; } -export function ReplyDialog({ children, shoutId }: ReplyDialogProps) { +export function ReplyDialog({ + recipientHandle, + children, + shoutId, +}: ReplyDialogProps) { const [open, setOpen] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isAuthenticated, setIsAuthenticated] = useState(false); const [hasError, setHasError] = useState(false); + const [replyError, setReplyError] = useState(); + const replyToShout = useReplyToShout(); useEffect(() => { UserService.getMe() @@ -52,31 +58,23 @@ export function ReplyDialog({ children, shoutId }: ReplyDialogProps) { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setIsLoading(true); - try { - const message = event.currentTarget.elements.message.value; - const files = event.currentTarget.elements.image.files; - let image; - if (files?.length) { - image = await MediaService.saveImage(files[0]); - } + const message = event.currentTarget.elements.message.value; + const files = Array.from(event.currentTarget.elements.image.files ?? []); - const newShout = await ShoutService.createShout({ - message, - imageId: image?.id, - }); - - await ShoutService.createReply({ - shoutId, - replyId: newShout.id, - }); + const result = await replyToShout({ + recipientHandle, + message, + files, + shoutId, + }); + if (result.error) { + setReplyError(result.error); + } else { setOpen(false); - } catch (error) { - console.error(error); - } finally { - setIsLoading(false); } + setIsLoading(false); } return ( @@ -114,6 +112,11 @@ export function ReplyDialog({ children, shoutId }: ReplyDialogProps) { Shout out! + {replyError && ( +
+ {replyError} +
+ )} diff --git a/src/components/shout/shout.tsx b/src/components/shout/shout.tsx index 36873cc..4c77c4d 100644 --- a/src/components/shout/shout.tsx +++ b/src/components/shout/shout.tsx @@ -25,6 +25,7 @@ const defaultAuthor: User = { handle: "Deleted", avatar: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTIgMGMtNi42MjcgMC0xMiA1LjM3My0xMiAxMnM1LjM3MyAxMiAxMiAxMiAxMi01LjM3MyAxMi0xMi01LjM3My0xMi0xMi0xMnptOSAxMmMwIDEuOTQtLjYyNCAzLjczNS0xLjY3MiA1LjIwN2wtMTIuNTM1LTEyLjUzNWMxLjQ3Mi0xLjA0OCAzLjI2Ny0xLjY3MiA1LjIwNy0xLjY3MiA0Ljk2MiAwIDkgNC4wMzggOSA5em0tMTggMGMwLTEuOTQuNjI0LTMuNzM1IDEuNjcyLTUuMjA3bDEyLjUzNCAxMi41MzRjLTEuNDcxIDEuMDQ5LTMuMjY2IDEuNjczLTUuMjA2IDEuNjczLTQuOTYyIDAtOS00LjAzOC05LTl6Ii8+PC9zdmc+", + blockedUserIds: [], followerIds: [], }; @@ -59,7 +60,7 @@ export function Shout({ shout, author = defaultAuthor, image }: ShoutProps) { )} - +