diff --git a/apps/web/schema.graphql b/apps/web/schema.graphql index 92f2c00e..fa48a7ea 100644 --- a/apps/web/schema.graphql +++ b/apps/web/schema.graphql @@ -154,6 +154,9 @@ type User { """ユーザー名""" name: String! + """ユーザーが受信したフィードバック""" + receivedFeedbacks(after: String, before: String, first: Int, last: Int): UserReceivedFeedbacksConnection! + """ユーザーに紐づくSlackユーザー""" slackUsers: [SlackUser!]! @@ -161,6 +164,17 @@ type User { type: UserType! } +type UserReceivedFeedbacksConnection { + edges: [UserReceivedFeedbacksConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type UserReceivedFeedbacksConnectionEdge { + cursor: String! + node: Feedback! +} + """ユーザー種別""" enum UserType { designer diff --git a/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/loading.tsx b/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/loading.tsx new file mode 100644 index 00000000..476da7e2 --- /dev/null +++ b/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/loading.tsx @@ -0,0 +1,20 @@ +import * as styles from './page.css'; +import { FeedbackCard } from '../../../../../components/domains/feedbacks/feedback-card'; + +import type { FC } from 'react'; + +export type UserDetailsFeedbacksLoadingPageProps = { + // +}; + +const UserDetailsFeedbacksLoadingPage: FC = () => { + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ); +}; + +export default UserDetailsFeedbacksLoadingPage; diff --git a/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/page.css.ts b/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/page.css.ts new file mode 100644 index 00000000..684966d8 --- /dev/null +++ b/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/page.css.ts @@ -0,0 +1,7 @@ +import { style } from '@vanilla-extract/css'; + +export const wrapper = style({ + display: 'flex', + flexDirection: 'column', + gap: '16px', +}); diff --git a/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/page.tsx b/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/page.tsx index 219656ae..e0f7c195 100644 --- a/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/page.tsx +++ b/apps/web/src/app/(authorized)/users/[userId]/@feedbacks/page.tsx @@ -1,6 +1,31 @@ +import * as styles from './page.css'; +import { graphql } from '../../../../../../.graphql'; +import { FeedbackCard } from '../../../../../components/domains/feedbacks/feedback-card'; +import { InfiniteScroll } from '../../../../../components/elements/infinite-scroll'; +import { graphqlExecutor } from '../../../../../graphql'; + +import type { InfiniteScrollFetcher } from '../../../../../components/elements/infinite-scroll'; import type { FC } from 'react'; -export type UserDetailsReceivedFeedbacksPageProps = { +const limit = 10; + +const UserDetailsFeedbacksPageReceivedQuery = graphql(/* GraphQL */ ` + query UserDetailsFeedbacksPageReceived($userId: ID!, $limit: Int!, $cursor: String) { + userById(userId: $userId) { + receivedFeedbacks(first: $limit, after: $cursor) { + edges { + cursor + node { + id + ...FeedbackCard + } + } + } + } + } +`); + +export type UserDetailsFeedbacksPageProps = { params: { userId: string; }; @@ -9,8 +34,42 @@ export type UserDetailsReceivedFeedbacksPageProps = { }; }; -const UserDetailsReceivedFeedbacksPage: FC = () => { - return null; +const UserDetailsFeedbacksPage: FC = async ({ + params, + searchParams, +}) => { + if (searchParams.tab === 'sent') { + return null; + } + + const data = await graphqlExecutor({ + document: UserDetailsFeedbacksPageReceivedQuery, + variables: { userId: params.userId, limit }, + }); + + const fetcher: InfiniteScrollFetcher = async ({ cursor }) => { + 'use server'; + const data = await graphqlExecutor({ + document: UserDetailsFeedbacksPageReceivedQuery, + variables: { userId: params.userId, limit, cursor }, + }); + + return data.userById.receivedFeedbacks.edges.map((edge) => ({ + cursor: edge.cursor, + node: , + })); + }; + + return ( + ({ + cursor: edge.cursor, + node: , + }))} + fetcher={fetcher} + /> + ); }; -export default UserDetailsReceivedFeedbacksPage; +export default UserDetailsFeedbacksPage; diff --git a/apps/web/src/components/domains/users/user-feedback-list-tabs/user-feedback-list-tabs.css.tsx b/apps/web/src/components/domains/users/user-feedback-list-tabs/user-feedback-list-tabs.css.tsx index 7adb0735..4d238d0b 100644 --- a/apps/web/src/components/domains/users/user-feedback-list-tabs/user-feedback-list-tabs.css.tsx +++ b/apps/web/src/components/domains/users/user-feedback-list-tabs/user-feedback-list-tabs.css.tsx @@ -3,6 +3,9 @@ import { style } from '@vanilla-extract/css'; import { theme } from '../../../../themes'; export const content = style({ + display: 'flex', + flexDirection: 'column', + gap: '16px', marginTop: '16px', }); diff --git a/apps/web/src/graphql/resolvers/users/fields/received-feedbacks.ts b/apps/web/src/graphql/resolvers/users/fields/received-feedbacks.ts new file mode 100644 index 00000000..4d684920 --- /dev/null +++ b/apps/web/src/graphql/resolvers/users/fields/received-feedbacks.ts @@ -0,0 +1,62 @@ +import { resolveCursorConnection } from '@pothos/plugin-relay'; +import * as v from 'valibot'; + +import { builder } from '../../../core/builder'; +import { deserialize } from '../../../helpers/deserialize'; +import { serialize } from '../../../helpers/serialize'; +import { userReceivedFeedbacksCount } from '../../../usecases/users/fields/received-feedback-count'; +import { userReceivedFeedbacks } from '../../../usecases/users/fields/received-feedbacks'; +import { Feedback } from '../../feedbacks/types/feedback'; +import { User } from '../types/user'; + +import type { ResolveCursorConnectionArgs } from '@pothos/plugin-relay'; + +builder.objectField(User, 'receivedFeedbacks', (t) => t.connection({ + type: Feedback, + description: 'ユーザーが受信したフィードバック', + resolve: async (user, args) => { + const connection = await resolveCursorConnection( + { + args, + toCursor: (feedback) => serialize(feedback.cursor), + }, + async ({ after, limit }: ResolveCursorConnectionArgs) => { + const result = await userReceivedFeedbacks({ + userId: user.id, + limit: limit, + cursor: after ? deserialize(after, v.object({ + id: v.string(), + createdAt: v.pipe( + v.string(), + v.isoTimestamp(), + v.transform((value) => new Date(value)), + ), + })) : undefined, + }); + + if (result.isOk()) { + return result.value; + } + + throw result.error; + }, + ); + + const totalCount = async () => { + const result = await userReceivedFeedbacksCount({ + userId: user.id, + }); + + if (result.isOk()) { + return result.value; + } + + throw result.error; + }; + + return { + ...connection, + totalCount, + }; + }, +})); diff --git a/apps/web/src/graphql/resolvers/users/index.ts b/apps/web/src/graphql/resolvers/users/index.ts index 470e5017..3883cad0 100644 --- a/apps/web/src/graphql/resolvers/users/index.ts +++ b/apps/web/src/graphql/resolvers/users/index.ts @@ -1,3 +1,4 @@ +import './fields/received-feedbacks'; import './fields/slack-users'; import './queries/me'; import './queries/user-by-id'; diff --git a/apps/web/src/graphql/usecases/users/fields/received-feedback-count.ts b/apps/web/src/graphql/usecases/users/fields/received-feedback-count.ts new file mode 100644 index 00000000..58e63ede --- /dev/null +++ b/apps/web/src/graphql/usecases/users/fields/received-feedback-count.ts @@ -0,0 +1,49 @@ +import { CustomError } from '@feedbackun/package-custom-error'; +import { database, schema } from '@feedbackun/package-database'; +import DataLoader from 'dataloader'; +import { count, eq } from 'drizzle-orm'; +import { ResultAsync } from 'neverthrow'; + +import { serialize } from '../../../helpers/serialize'; +import { dataLoader } from '../../../plugins/dataloader'; + +const symbol = Symbol('UserReceivedFeedbacksCount'); + +export type UserReceivedFeedbacksCountInput = { + userId: string; +}; + +export class UserReceivedFeedbacksCountUnexpectedError extends CustomError({ + name: 'UserReceivedFeedbacksCountUnexpectedError', + message: 'Failed to count received feedbacks for user.', +}) {} + +export type UserReceivedFeedbacksCountError = ( + | UserReceivedFeedbacksCountUnexpectedError +); + +export type UserReceivedFeedbacksCount = ( + input: UserReceivedFeedbacksCountInput, +) => ResultAsync; + +export const userReceivedFeedbacksCount: UserReceivedFeedbacksCount = (input) => { + const loader = dataLoader(symbol, () => new DataLoader(async (inputs) => { + const execute = async (input: UserReceivedFeedbacksCountInput): Promise => { + const [row] = await database() + .select({ count: count() }) + .from(schema.feedbacks) + .innerJoin(schema.slackUsers, eq(schema.slackUsers.id, schema.feedbacks.receiveSlackUserId)) + .innerJoin(schema.users, eq(schema.users.id, schema.slackUsers.userId)) + .where(eq(schema.users.id, input.userId)); + + return row?.count ?? 0; + }; + + return await Promise.all(inputs.map((input) => execute(input))); + }, { cacheKeyFn: serialize })); + + return ResultAsync.fromThrowable( + () => loader.load(input), + (error) => new UserReceivedFeedbacksCountUnexpectedError({ cause: error }), + )(); +}; diff --git a/apps/web/src/graphql/usecases/users/fields/received-feedbacks.ts b/apps/web/src/graphql/usecases/users/fields/received-feedbacks.ts new file mode 100644 index 00000000..6592b771 --- /dev/null +++ b/apps/web/src/graphql/usecases/users/fields/received-feedbacks.ts @@ -0,0 +1,85 @@ +import { CustomError } from '@feedbackun/package-custom-error'; +import { database, schema } from '@feedbackun/package-database'; +import DataLoader from 'dataloader'; +import { and, desc, eq, lt, or } from 'drizzle-orm'; +import { ResultAsync } from 'neverthrow'; + +import { serialize } from '../../../helpers/serialize'; +import { dataLoader } from '../../../plugins/dataloader'; + +import type { Feedback } from '../../feedbacks/types/feedback'; + +const symbol = Symbol('UserReceivedFeedbacks'); + +export type UserReceivedFeedbacksCursor = { + id: string; + createdAt: Date; +}; + +export type UserReceivedFeedbacksInput = { + userId: string; + limit: number; + cursor: UserReceivedFeedbacksCursor | undefined; +}; + +export class UserReceivedFeedbacksUnexpectedError extends CustomError({ + name: 'UserReceivedFeedbacksUnexpectedError', + message: 'Failed to find received feedbacks for user.', +}) {} + +export type UserReceivedFeedbacksError = ( + | UserReceivedFeedbacksUnexpectedError +); + +export type UserReceivedFeedbacksNode = Feedback & { cursor: UserReceivedFeedbacksCursor }; + +export type UserReceivedFeedbacks = ( + input: UserReceivedFeedbacksInput, +) => ResultAsync; + +export const userReceivedFeedbacks: UserReceivedFeedbacks = (input) => { + const loader = dataLoader(symbol, () => new DataLoader(async (inputs) => { + const execute = async (input: UserReceivedFeedbacksInput): Promise => { + const filters: Parameters = [ + eq(schema.users.id, input.userId), + ]; + + if (input.cursor) { + filters.push( + or( + lt(schema.feedbacks.createdAt, input.cursor.createdAt), + and( + eq(schema.feedbacks.createdAt, input.cursor.createdAt), + lt(schema.feedbacks.id, input.cursor.id), + ), + ), + ); + } + + const rows = await database() + .select() + .from(schema.feedbacks) + .innerJoin(schema.slackUsers, eq(schema.slackUsers.id, schema.feedbacks.receiveSlackUserId)) + .innerJoin(schema.users, eq(schema.users.id, schema.slackUsers.userId)) + .where(and(...filters)) + .orderBy(desc(schema.feedbacks.createdAt)); + + return rows.map((row) => ({ + id: row.feedbacks.id, + content: row.feedbacks.content, + createdAt: row.feedbacks.createdAt, + cursor: { + id: row.feedbacks.id, + createdAt: row.feedbacks.createdAt, + }, + })); + }; + + return await Promise.all(inputs.map((input) => execute(input))); + }, { cacheKeyFn: serialize })); + + return ResultAsync.fromThrowable( + () => loader.load(input), + (error) => new UserReceivedFeedbacksUnexpectedError({ cause: error }), + )(); +};