diff --git a/docker/keycloak/realm-export.json b/docker/keycloak/realm-export.json index 0aebab4d..acf72774 100644 --- a/docker/keycloak/realm-export.json +++ b/docker/keycloak/realm-export.json @@ -666,7 +666,8 @@ "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", "redirectUris": [ - "http://localhost:3000/rpc/domain/authentication/login/feature" + "http://localhost:3000/rpc/domain/authentication/login", + "http://localhost:5173/rpc/domain/authentication/login" ], "webOrigins": [ "https://www.keycloak.org" diff --git a/segments/bff.json b/segments/bff.json index b15ab48a..ea14b39e 100644 --- a/segments/bff.json +++ b/segments/bff.json @@ -24,6 +24,7 @@ "./domain/reaction/createWithComment": { "default": { "access": "public" } }, "./domain/reaction/getByIdAggregated": { "default": { "access": "public" } }, "./domain/reaction/getByPostAggregated": { "default": { "access": "public" } }, + "./domain/reaction/getByReactionAggregated": { "default": { "access": "public"} }, "./domain/reaction/remove": { "default": { "access": "public" } }, "./domain/reaction/toggleRating": { "default": { "access": "public" } }, diff --git a/src/domain/notification/aggregate/aggregate.ts b/src/domain/notification/aggregate/aggregate.ts index 21c01170..9023887b 100644 --- a/src/domain/notification/aggregate/aggregate.ts +++ b/src/domain/notification/aggregate/aggregate.ts @@ -9,10 +9,11 @@ import type { AggregatedData } from './types'; export default async function aggregate(requester: Requester, data: DataModel): Promise { - const [relationData, postData, reactionData] = await Promise.all([ + const [relationData, postData, targetReactionData, sourceReactionData] = await Promise.all([ getRelationData(data.receiverId, data.senderId), - data.postId ? getPostData(requester, data.postId) : undefined, - data.reactionId ? getReactionData(requester, data.reactionId) : undefined + data.targetPostId ? getPostData(requester, data.targetPostId) : undefined, + data.targetReactionId ? getReactionData(requester, data.targetReactionId) : undefined, + data.sourceReactionId ? getReactionData(requester, data.sourceReactionId) : undefined ]); return { @@ -20,7 +21,8 @@ export default async function aggregate(requester: Requester, data: DataModel): createdAt: data.createdAt, type: data.type, relation: relationData, - post: postData, - reaction: reactionData + targetPost: postData, + targetReaction: targetReactionData, + sourceReaction: sourceReactionData }; } diff --git a/src/domain/notification/aggregate/types.ts b/src/domain/notification/aggregate/types.ts index 7b1434f5..e1f5d63f 100644 --- a/src/domain/notification/aggregate/types.ts +++ b/src/domain/notification/aggregate/types.ts @@ -8,8 +8,9 @@ import { DataModel } from '../types'; type AggregatedData = Pick & { readonly relation: AggregatedRelationData; - readonly post?: AggregatedPostData; - readonly reaction?: AggregatedReactionData; + readonly targetPost?: AggregatedPostData; + readonly targetReaction?: AggregatedReactionData; + readonly sourceReaction?: AggregatedReactionData; }; export type { AggregatedData }; diff --git a/src/domain/notification/create/create.ts b/src/domain/notification/create/create.ts index e09126d2..126348b3 100644 --- a/src/domain/notification/create/create.ts +++ b/src/domain/notification/create/create.ts @@ -5,14 +5,14 @@ import { Type } from '../definitions'; import createData from './createData'; import insertData from './insertData'; -export default async function feature(type: Type, senderId: string, receiverId: string, postId: string | undefined = undefined, reactionId: string | undefined = undefined): Promise +export default async function feature(type: Type, senderId: string, receiverId: string, targetPostId: string | undefined = undefined, targetReactionId: string | undefined = undefined, sourceReactionId: string | undefined = undefined): Promise { if (senderId === receiverId) { return; } - const data = createData(type, senderId, receiverId, postId, reactionId); + const data = createData(type, senderId, receiverId, targetPostId, targetReactionId, sourceReactionId); try { diff --git a/src/domain/notification/create/createData.ts b/src/domain/notification/create/createData.ts index c36b5e90..31cb06b9 100644 --- a/src/domain/notification/create/createData.ts +++ b/src/domain/notification/create/createData.ts @@ -3,7 +3,7 @@ import { generateId } from '^/integrations/utilities/crypto'; import { DataModel } from '../types'; -export default function createData(type: string, senderId: string, receiverId: string, postId: string | undefined = undefined, reactionId: string | undefined = undefined): DataModel +export default function createData(type: string, senderId: string, receiverId: string, targetPostId: string | undefined = undefined, targetReactionId: string | undefined = undefined, sourceReactionId: string | undefined = undefined): DataModel { return { id: generateId(), @@ -11,7 +11,8 @@ export default function createData(type: string, senderId: string, receiverId: s type, senderId, receiverId, - postId, - reactionId + targetPostId, + targetReactionId, + sourceReactionId }; } diff --git a/src/domain/notification/definitions.ts b/src/domain/notification/definitions.ts index 4bfc9128..bd2f47d9 100644 --- a/src/domain/notification/definitions.ts +++ b/src/domain/notification/definitions.ts @@ -5,7 +5,8 @@ export const Types = { STARTED_FOLLOWING: 'started-following', RATED_POST: 'rated-post', RATED_REACTION: 'rated-reaction', - ADDED_REACTION: 'added-reaction' + ADDED_REACTION_POST: 'added-reaction-post', + ADDED_REACTION_REACTION: 'added-reaction-reaction' } as const; type TypeKeys = keyof typeof Types; diff --git a/src/domain/notification/types.ts b/src/domain/notification/types.ts index f8c58578..2b7a6ab0 100644 --- a/src/domain/notification/types.ts +++ b/src/domain/notification/types.ts @@ -7,8 +7,9 @@ type DataModel = BaseDataModel & readonly type: string; readonly senderId: string; readonly receiverId: string; - readonly postId?: string; - readonly reactionId?: string; + readonly targetPostId?: string; + readonly targetReactionId?: string; + readonly sourceReactionId?: string; }; export type { DataModel }; diff --git a/src/domain/post/toggleRating/toggleRating.ts b/src/domain/post/toggleRating/toggleRating.ts index d29b277f..901f1221 100644 --- a/src/domain/post/toggleRating/toggleRating.ts +++ b/src/domain/post/toggleRating/toggleRating.ts @@ -28,7 +28,7 @@ export default async function toggleRating(requester: Requester, postId: string) const post = await getPost(postId); - await createNotification(Types.RATED_POST, requester.id, post.creatorId, postId); + await createNotification(Types.RATED_POST, requester.id, post.creatorId, postId, undefined); return true; } diff --git a/src/domain/reaction/remove/ReactionNotFound.ts b/src/domain/reaction/ReactionNotFound.ts similarity index 100% rename from src/domain/reaction/remove/ReactionNotFound.ts rename to src/domain/reaction/ReactionNotFound.ts diff --git a/src/domain/reaction/aggregate/aggregate.ts b/src/domain/reaction/aggregate/aggregate.ts index 57ca25e1..11fc86dd 100644 --- a/src/domain/reaction/aggregate/aggregate.ts +++ b/src/domain/reaction/aggregate/aggregate.ts @@ -21,8 +21,10 @@ export default async function aggregate(requester: Requester, data: DataModel): id: data.id, createdAt: data.createdAt, ratingCount: data.ratingCount, + reactionCount: data.reactionCount, creator: relationData, postId: data.postId, + reactionId: data.reactionId, hasRated, comic: comicData, comment: commentData, diff --git a/src/domain/reaction/aggregate/types.ts b/src/domain/reaction/aggregate/types.ts index cb126353..158b388e 100644 --- a/src/domain/reaction/aggregate/types.ts +++ b/src/domain/reaction/aggregate/types.ts @@ -1,11 +1,11 @@ import type { AggregatedData as AggregatedComicData } from '^/domain/comic/aggregate'; -import type { DataModel as CommentData } from '^/domain/comment/types'; +import type { DataModel as CommentData } from '^/domain/comment'; import type { AggregatedData as AggregatedRelationData } from '^/domain/relation/aggregate'; import type { DataModel } from '../types'; -type AggregatedData = Pick & +type AggregatedData = Pick & { readonly creator: AggregatedRelationData; readonly hasRated: boolean; diff --git a/src/domain/reaction/create/create.ts b/src/domain/reaction/create/create.ts index c04f822d..8674072f 100644 --- a/src/domain/reaction/create/create.ts +++ b/src/domain/reaction/create/create.ts @@ -1,33 +1,48 @@ import logger from '^/integrations/logging'; -import { Types } from '^/domain/notification'; import createNotification from '^/domain/notification/create'; +import { Types } from '^/domain/notification/definitions'; import retrievePost from '^/domain/post/getById'; -import updateReactionCount from '^/domain/post/updateReactionCount'; +import updatePostReactionCount from '^/domain/post/updateReactionCount'; + +import retrieveReaction from '../getById'; +import updateReactionReactionCount from '../updateReactionCount'; import createData from './createData'; import eraseData from './eraseData'; import insertData from './insertData'; import validateData from './validateData'; -export default async function create(creatorId: string, postId: string, comicId: string | undefined = undefined, commentId: string | undefined = undefined): Promise +export default async function feature(creatorId: string, postId: string | undefined = undefined, reactionId: string | undefined = undefined, comicId: string | undefined = undefined, commentId: string | undefined = undefined): Promise { let id; try { - const data = createData(creatorId, postId, comicId, commentId); + const data = createData(creatorId, postId, reactionId, comicId, commentId); validateData(data); id = await insertData(data); - await updateReactionCount(postId, 'increase'); + if (postId !== undefined) + { + await updatePostReactionCount(postId, 'increase'); + + const post = await retrievePost(postId); + + await createNotification(Types.ADDED_REACTION_POST, creatorId, post.creatorId, postId, undefined, id); + } + + if (reactionId !== undefined) + { + await updateReactionReactionCount(reactionId, 'increase'); - const post = await retrievePost(postId); + const reaction = await retrieveReaction(reactionId); - await createNotification(Types.ADDED_REACTION, creatorId, post.creatorId, postId, id); + await createNotification(Types.ADDED_REACTION_REACTION, creatorId, reaction.creatorId, undefined, reactionId, id); + } return id; } diff --git a/src/domain/reaction/create/createData.ts b/src/domain/reaction/create/createData.ts index 1baa7558..3952d8fb 100644 --- a/src/domain/reaction/create/createData.ts +++ b/src/domain/reaction/create/createData.ts @@ -3,15 +3,17 @@ import { generateId } from '^/integrations/utilities/crypto'; import { DataModel } from '../types'; -export default function createData(creatorId: string, postId: string, comicId: string | undefined = undefined, commentId: string | undefined = undefined): DataModel +export default function createData(creatorId: string, postId: string | undefined = undefined, reactionId: string | undefined = undefined, comicId: string | undefined = undefined, commentId: string | undefined = undefined): DataModel { return { id: generateId(), createdAt: new Date().toISOString(), creatorId, postId, + reactionId, comicId, commentId, - ratingCount: 0 + ratingCount: 0, + reactionCount: 0 }; } diff --git a/src/domain/reaction/create/types.ts b/src/domain/reaction/create/types.ts index 71e759c5..20fddeb9 100644 --- a/src/domain/reaction/create/types.ts +++ b/src/domain/reaction/create/types.ts @@ -1,6 +1,6 @@ import { DataModel } from '../types'; -type ValidationModel = Pick; +type ValidationModel = Pick; export type { ValidationModel }; diff --git a/src/domain/reaction/create/validateData.ts b/src/domain/reaction/create/validateData.ts index b499cb6c..4cd474a7 100644 --- a/src/domain/reaction/create/validateData.ts +++ b/src/domain/reaction/create/validateData.ts @@ -9,13 +9,23 @@ import { ValidationModel } from './types'; const schema: ValidationSchema = { creatorId: requiredIdValidation, - postId: requiredIdValidation, + postId: optionalIdValidation, + reactionId: optionalIdValidation, comicId: optionalIdValidation, commentId: optionalIdValidation }; -export default function validateData({ creatorId, postId, comicId, commentId }: ValidationModel): void +export default function validateData({ creatorId, postId, reactionId, comicId, commentId }: ValidationModel): void { + if (postId === undefined && reactionId === undefined) + { + const messages = new Map() + .set('postId', 'Either postId or reactionId must be provided') + .set('reactionId', 'Either postId or reactionId must be provided'); + + throw new InvalidReaction(messages); + } + if (comicId === undefined && commentId === undefined) { const messages = new Map() diff --git a/src/domain/reaction/createWithComic/createWithComic.ts b/src/domain/reaction/createWithComic/createWithComic.ts index 38957689..25ef2e2b 100644 --- a/src/domain/reaction/createWithComic/createWithComic.ts +++ b/src/domain/reaction/createWithComic/createWithComic.ts @@ -4,9 +4,9 @@ import createComic from '^/domain/comic/create'; import createReaction from '../create'; -export default async function createWithComic(requester: Requester, postId: string, imageData: string): Promise +export default async function createWithComic(requester: Requester, imageData: string, postId: string | undefined = undefined, reactionId: string | undefined = undefined): Promise { const comicId = await createComic(imageData); - return createReaction(requester.id, postId, comicId); + return createReaction(requester.id, postId, reactionId, comicId); } diff --git a/src/domain/reaction/createWithComment/createWithComment.ts b/src/domain/reaction/createWithComment/createWithComment.ts index 8fcba79b..70ffff71 100644 --- a/src/domain/reaction/createWithComment/createWithComment.ts +++ b/src/domain/reaction/createWithComment/createWithComment.ts @@ -4,9 +4,9 @@ import createComment from '^/domain/comment/create'; import createReaction from '../create'; -export default async function createWithComment(requester: Requester, postId: string, message: string): Promise +export default async function createWithComment(requester: Requester, message: string, postId: string | undefined = undefined, reactionId: string | undefined = undefined): Promise { const commentId = await createComment(message); - return createReaction(requester.id, postId, undefined, commentId); + return createReaction(requester.id, postId, reactionId, undefined, commentId); } diff --git a/src/domain/reaction/getById/getById.ts b/src/domain/reaction/getById/getById.ts index 954137d2..ce4e02b0 100644 --- a/src/domain/reaction/getById/getById.ts +++ b/src/domain/reaction/getById/getById.ts @@ -1,10 +1,24 @@ -import database from '^/integrations/database'; +import database, { RecordQuery } from '^/integrations/database'; import { RECORD_TYPE } from '../definitions'; +import ReactionNotFound from '../ReactionNotFound'; import type { DataModel } from '../types'; export default async function getById(id: string): Promise { - return database.readRecord(RECORD_TYPE, id) as Promise; + const query: RecordQuery = + { + id: { 'EQUALS': id }, + deleted: { 'EQUALS': false } + }; + + const record = await database.findRecord(RECORD_TYPE, query); + + if (record === undefined) + { + throw new ReactionNotFound(); + } + + return record as DataModel; } diff --git a/src/domain/reaction/getByReaction/getByReaction.ts b/src/domain/reaction/getByReaction/getByReaction.ts new file mode 100644 index 00000000..64427c08 --- /dev/null +++ b/src/domain/reaction/getByReaction/getByReaction.ts @@ -0,0 +1,18 @@ + +import database, { RecordQuery, RecordSort, SortDirections } from '^/integrations/database'; + +import { RECORD_TYPE } from '../definitions'; +import type { DataModel } from '../types'; + +export default async function getByReaction(reactionId: string, limit: number, offset: number): Promise +{ + const query: RecordQuery = + { + reactionId: { 'EQUALS': reactionId }, + deleted: { 'EQUALS': false } + }; + + const sort: RecordSort = { createdAt: SortDirections.DESCENDING }; + + return database.searchRecords(RECORD_TYPE, query, undefined, sort, limit, offset) as Promise; +} diff --git a/src/domain/reaction/getByReaction/index.ts b/src/domain/reaction/getByReaction/index.ts new file mode 100644 index 00000000..c681a8a1 --- /dev/null +++ b/src/domain/reaction/getByReaction/index.ts @@ -0,0 +1,2 @@ + +export { default } from './getByReaction'; diff --git a/src/domain/reaction/getByReactionAggregated/getByReactionAggregated.ts b/src/domain/reaction/getByReactionAggregated/getByReactionAggregated.ts new file mode 100644 index 00000000..e73c67c6 --- /dev/null +++ b/src/domain/reaction/getByReactionAggregated/getByReactionAggregated.ts @@ -0,0 +1,14 @@ + +import type { Requester } from '^/domain/authentication/types'; +import { Range } from '^/domain/common/validateRange'; + +import aggregate from '../aggregate'; +import type { AggregatedData } from '../aggregate/types'; +import getByReaction from '../getByReaction/getByReaction'; + +export default async function getByReactionAggregated(requester: Requester, reactionId: string, range: Range): Promise +{ + const data = await getByReaction(reactionId, range.limit, range.offset); + + return Promise.all(data.map(item => aggregate(requester, item))); +} diff --git a/src/domain/reaction/getByReactionAggregated/index.ts b/src/domain/reaction/getByReactionAggregated/index.ts new file mode 100644 index 00000000..9452a0f6 --- /dev/null +++ b/src/domain/reaction/getByReactionAggregated/index.ts @@ -0,0 +1,2 @@ + +export { default } from './getByReactionAggregated'; diff --git a/src/domain/reaction/index.ts b/src/domain/reaction/index.ts index 273c9eea..0dc6060a 100644 --- a/src/domain/reaction/index.ts +++ b/src/domain/reaction/index.ts @@ -2,3 +2,5 @@ export { RECORD_TYPE } from './definitions'; export type { DataModel } from './types'; + +export { default as ReactionNotFound } from './ReactionNotFound'; diff --git a/src/domain/reaction/remove/index.ts b/src/domain/reaction/remove/index.ts index e9da62e9..16bee921 100644 --- a/src/domain/reaction/remove/index.ts +++ b/src/domain/reaction/remove/index.ts @@ -1,4 +1,2 @@ export { default } from './remove'; - -export { default as ReactionNotFound } from './ReactionNotFound'; diff --git a/src/domain/reaction/remove/remove.ts b/src/domain/reaction/remove/remove.ts index b13f6607..0a9b7262 100644 --- a/src/domain/reaction/remove/remove.ts +++ b/src/domain/reaction/remove/remove.ts @@ -1,10 +1,11 @@ import logger from '^/integrations/logging'; -import { Requester } from '^/domain/authentication'; -import updateReactionCount from '^/domain/post/updateReactionCount'; +import type { Requester } from '^/domain/authentication/types'; +import updatePostReactionCount from '^/domain/post/updateReactionCount'; +import ReactionNotFound from '^/domain/reaction/ReactionNotFound'; +import updateReactionReactionCount from '^/domain/reaction/updateReactionCount'; -import ReactionNotFound from './ReactionNotFound'; import deleteData from './deleteData'; import retrieveOwnedData from './retrieveOwnedData'; @@ -20,11 +21,20 @@ export default async function remove(requester: Requester, id: string): Promise< throw new ReactionNotFound(); } - let reactionCount; + let postReactionCount; + let reactionReactionCount; try { - reactionCount = await updateReactionCount(reaction.postId, 'decrease'); + if (reaction.postId !== undefined) + { + postReactionCount = await updatePostReactionCount(reaction.postId, 'decrease'); + } + + if (reaction.reactionId !== undefined) + { + reactionReactionCount = await updateReactionReactionCount(reaction.reactionId, 'decrease'); + } await deleteData(reaction.id); } @@ -32,9 +42,14 @@ export default async function remove(requester: Requester, id: string): Promise< { logger.logError('Failed to remove reaction', error); - if (reactionCount !== undefined) + if (postReactionCount !== undefined) + { + await updatePostReactionCount(reaction.postId as string, 'increase'); + } + + if (reactionReactionCount !== undefined) { - await updateReactionCount(reaction.postId, 'increase'); + await updateReactionReactionCount(reaction.reactionId as string, 'increase'); } throw error; diff --git a/src/domain/reaction/types.ts b/src/domain/reaction/types.ts index b2285356..c21f8701 100644 --- a/src/domain/reaction/types.ts +++ b/src/domain/reaction/types.ts @@ -5,10 +5,12 @@ type DataModel = BaseDataModel & { readonly createdAt: string; readonly creatorId: string; - readonly postId: string; + readonly postId: string | undefined; + readonly reactionId: string | undefined; readonly comicId: string | undefined; readonly commentId: string | undefined; readonly ratingCount: number; + readonly reactionCount: number; }; export type { CountOperation, DataModel }; diff --git a/src/domain/reaction/updateReactionCount/index.ts b/src/domain/reaction/updateReactionCount/index.ts new file mode 100644 index 00000000..dbb4f583 --- /dev/null +++ b/src/domain/reaction/updateReactionCount/index.ts @@ -0,0 +1,2 @@ + +export { default } from './updateReactionCount'; diff --git a/src/domain/reaction/updateReactionCount/updateReactionCount.ts b/src/domain/reaction/updateReactionCount/updateReactionCount.ts new file mode 100644 index 00000000..5b3664fd --- /dev/null +++ b/src/domain/reaction/updateReactionCount/updateReactionCount.ts @@ -0,0 +1,18 @@ + +import getById from '../getById'; +import update from '../update'; + +import type { CountOperation } from '../types'; + +export default async function updateReactionCount(id: string, operation: CountOperation): Promise +{ + const data = await getById(id); + + const reactionCount = operation === 'increase' + ? data.reactionCount + 1 + : data.reactionCount - 1; + + await update(id, { reactionCount }); + + return reactionCount; +} diff --git a/src/webui/Routes.tsx b/src/webui/Routes.tsx index 6dd16713..9bbf5a31 100644 --- a/src/webui/Routes.tsx +++ b/src/webui/Routes.tsx @@ -13,8 +13,10 @@ import Logout from './features/Logout'; import NotFound from './features/NotFound'; import Notifications from './features/Notifications'; import PostDetails from './features/PostDetails'; +import PostHighlight from './features/PostHighlight'; import Profile from './features/Profile'; import ReactionDetails from './features/ReactionDetails'; +import ReactionHighlight from './features/ReactionHighlight'; import Timeline from './features/Timeline'; export default function Component() @@ -39,7 +41,9 @@ export default function Component() )} /> )} /> )} /> - )} /> + )} /> + )} /> + )} /> )} /> } /> diff --git a/src/webui/components/common/BackButton.tsx b/src/webui/components/common/BackButton.tsx new file mode 100644 index 00000000..9b1d3aca --- /dev/null +++ b/src/webui/components/common/BackButton.tsx @@ -0,0 +1,15 @@ + +import { Button } from '^/webui/designsystem'; + +type Props = { + readonly onClick: () => void; +}; + +export default function Component({ onClick }: Props) +{ + return