From 55245545eeaa5c10f18922d4793f9ed730736de5 Mon Sep 17 00:00:00 2001 From: BBAKJUN Date: Wed, 7 Jan 2026 15:36:02 +0900 Subject: [PATCH 1/4] feat(graphql): add organization member queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add queries to look up organization members with filtering by status. - Add `pendingOrganizationMembers(organizationSlug)` to query PENDING members - Add `organizationMembers(organizationSlug, status?)` to query all members with optional status filter - Add organization-member.mapper.ts for domain to GraphQL transformation - Update mappers and resolvers index to export new modules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/presentation/graphql/mappers/index.ts | 1 + .../mappers/organization-member.mapper.ts | 11 ++ .../presentation/graphql/resolvers/index.ts | 6 + .../resolvers/organization-member.resolver.ts | 137 ++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 apps/server/src/presentation/graphql/mappers/organization-member.mapper.ts create mode 100644 apps/server/src/presentation/graphql/resolvers/organization-member.resolver.ts diff --git a/apps/server/src/presentation/graphql/mappers/index.ts b/apps/server/src/presentation/graphql/mappers/index.ts index 1988421..49d27f1 100644 --- a/apps/server/src/presentation/graphql/mappers/index.ts +++ b/apps/server/src/presentation/graphql/mappers/index.ts @@ -4,3 +4,4 @@ export * from './member.mapper'; export * from './generation.mapper'; export * from './cycle.mapper'; export * from './organization.mapper'; +export * from './organization-member.mapper'; diff --git a/apps/server/src/presentation/graphql/mappers/organization-member.mapper.ts b/apps/server/src/presentation/graphql/mappers/organization-member.mapper.ts new file mode 100644 index 0000000..2794a0b --- /dev/null +++ b/apps/server/src/presentation/graphql/mappers/organization-member.mapper.ts @@ -0,0 +1,11 @@ +// OrganizationMember Mapper + +import { OrganizationMember } from '@/domain/organization-member/organization-member.domain'; +import { GqlOrganizationMember, GqlMember } from '../types'; + +export const domainToGraphqlOrganizationMember = ( + organizationMember: OrganizationMember, + member?: GqlMember +): GqlOrganizationMember => { + return new GqlOrganizationMember(organizationMember, member); +}; diff --git a/apps/server/src/presentation/graphql/resolvers/index.ts b/apps/server/src/presentation/graphql/resolvers/index.ts index 1ba2734..5b8bfb3 100644 --- a/apps/server/src/presentation/graphql/resolvers/index.ts +++ b/apps/server/src/presentation/graphql/resolvers/index.ts @@ -7,6 +7,10 @@ import { organizationQueries, organizationMutations, } from './organization.resolver'; +import { + organizationMemberQueries, + organizationMemberMutations, +} from './organization-member.resolver'; // ======================================== // Query Resolvers 합치기 @@ -17,6 +21,7 @@ export const queryResolvers = { ...generationQueries, ...cycleQueries, ...organizationQueries, + ...organizationMemberQueries, }; // ======================================== @@ -28,4 +33,5 @@ export const mutationResolvers = { ...generationMutations, ...cycleMutations, ...organizationMutations, + ...organizationMemberMutations, }; diff --git a/apps/server/src/presentation/graphql/resolvers/organization-member.resolver.ts b/apps/server/src/presentation/graphql/resolvers/organization-member.resolver.ts new file mode 100644 index 0000000..7c39ad3 --- /dev/null +++ b/apps/server/src/presentation/graphql/resolvers/organization-member.resolver.ts @@ -0,0 +1,137 @@ +// OrganizationMember Domain Resolvers + +import { GetOrganizationMembersQuery } from '@/application'; +import { ChangeMemberRoleCommand } from '@/application'; +import { DrizzleOrganizationMemberRepository } from '@/infrastructure/persistence/drizzle'; +import { DrizzleOrganizationRepository } from '@/infrastructure/persistence/drizzle'; +import { DrizzleMemberRepository } from '@/infrastructure/persistence/drizzle'; +import { GqlOrganizationMember } from '../types'; +import { + OrganizationMemberStatus, + OrganizationRole, +} from '@/domain/organization-member/organization-member.vo'; +import { OrganizationMember } from '@/domain/organization-member/organization-member.domain'; +import { Member } from '@/domain/member/member.domain'; +import type { OrganizationMemberDTO } from '@/application/queries/get-organization-members.query'; + +// ======================================== +// Repository Instances +// ======================================== + +const organizationMemberRepo = new DrizzleOrganizationMemberRepository(); +const organizationRepo = new DrizzleOrganizationRepository(); +const memberRepo = new DrizzleMemberRepository(); + +// ======================================== +// Query & Command Instances +// ======================================== + +const getOrganizationMembersQuery = new GetOrganizationMembersQuery( + organizationRepo, + organizationMemberRepo, + memberRepo +); + +const changeMemberRoleCommand = new ChangeMemberRoleCommand( + organizationRepo, + memberRepo, + organizationMemberRepo +); + +// ======================================== +// Helper Functions +// ======================================== + +// DTO를 GraphQL 타입으로 변환하는 헬퍼 함수 +function mapToGqlOrganizationMember( + memberDTO: OrganizationMemberDTO +): GqlOrganizationMember { + // OrganizationMember 도메인 객체 생성 + const organizationMember = OrganizationMember.reconstitute({ + id: memberDTO.id, + organizationId: 0, // Not needed for this response + memberId: memberDTO.memberId, + role: memberDTO.role, + status: memberDTO.status, + joinedAt: new Date(memberDTO.joinedAt), + updatedAt: new Date(), + }); + + // Member 도메인 객체 생성 + const member = Member.reconstitute({ + id: memberDTO.memberId, + discordId: memberDTO.memberDiscordId, + name: memberDTO.memberName, + createdAt: new Date(), + }); + + return new GqlOrganizationMember(organizationMember, { + id: member.id.value, + github: member.githubUsername?.value ?? '', + discordId: member.discordId.value, + name: member.name.value, + createdAt: member.createdAt.toISOString(), + }); +} + +// ======================================== +// Resolvers +// ======================================== + +export const organizationMemberQueries = { + // 조직의 가입 신청 중(PENDING)인 멤버 목록 조회 + pendingOrganizationMembers: async ( + organizationSlug: string + ): Promise => { + const result = await getOrganizationMembersQuery.execute({ + organizationSlug, + status: OrganizationMemberStatus.PENDING, + }); + + return result.members.map(mapToGqlOrganizationMember); + }, + + // 조직의 전체 멤버 목록 조회 (상태 필터 지원) + organizationMembers: async ( + organizationSlug: string, + status?: string + ): Promise => { + const result = await getOrganizationMembersQuery.execute({ + organizationSlug, + status: status as OrganizationMemberStatus, + }); + + return result.members.map(mapToGqlOrganizationMember); + }, +}; + +export const organizationMemberMutations = { + // 조직원 역할 변경 + changeMemberRole: async ( + organizationSlug: string, + memberDiscordId: string, + newRole: string + ): Promise => { + const result = await changeMemberRoleCommand.execute({ + organizationSlug, + memberDiscordId, + newRole: newRole.toUpperCase() as OrganizationRole, + }); + + // Member 도메인 객체 생성 + const member = Member.reconstitute({ + id: result.member.id.value, + discordId: result.member.discordId.value, + name: result.member.name.value, + createdAt: result.member.createdAt, + }); + + return new GqlOrganizationMember(result.organizationMember, { + id: member.id.value, + github: member.githubUsername?.value ?? '', + discordId: member.discordId.value, + name: member.name.value, + createdAt: member.createdAt.toISOString(), + }); + }, +}; From df8c8ce53046a32b8f55b8941f83340a52a0500f Mon Sep 17 00:00:00 2001 From: BBAKJUN Date: Wed, 7 Jan 2026 15:36:19 +0900 Subject: [PATCH 2/4] feat(graphql): add change member role mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mutation to change organization member role. - Add ChangeMemberRoleCommand to handle role changes - Add `changeMemberRole(organizationSlug, memberDiscordId, newRole)` mutation - Supports OWNER, ADMIN, MEMBER roles - Update commands index to export new command 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../commands/change-member-role.command.ts | 94 +++++++++++++++++++ apps/server/src/application/commands/index.ts | 1 + 2 files changed, 95 insertions(+) create mode 100644 apps/server/src/application/commands/change-member-role.command.ts diff --git a/apps/server/src/application/commands/change-member-role.command.ts b/apps/server/src/application/commands/change-member-role.command.ts new file mode 100644 index 0000000..431a532 --- /dev/null +++ b/apps/server/src/application/commands/change-member-role.command.ts @@ -0,0 +1,94 @@ +// ChangeMemberRoleCommand - 조직원 역할 변경 Command + +import { Organization } from '../../domain/organization/organization.domain'; +import { Member } from '../../domain/member/member.domain'; +import { + OrganizationMember, + OrganizationRole, +} from '../../domain/organization-member/organization-member.domain'; +import { OrganizationRepository } from '../../domain/organization/organization.repository'; +import { MemberRepository } from '../../domain/member/member.repository'; +import { OrganizationMemberRepository } from '../../domain/organization-member/organization-member.repository'; + +/** + * 조직원 역할 변경 요청 데이터 + */ +export interface ChangeMemberRoleRequest { + organizationSlug: string; + memberDiscordId: string; + newRole: OrganizationRole; +} + +/** + * 조직원 역할 변경 결과 + */ +export interface ChangeMemberRoleResult { + organizationMember: OrganizationMember; + organization: Organization; + member: Member; +} + +/** + * 조직원 역할 변경 Command (Use Case) + * + * 책임: + * 1. 조직 존재 확인 + * 2. 멤버 존재 확인 + * 3. 조직원 존재 확인 + * 4. 역할 변경 + * 5. 저장 + */ +export class ChangeMemberRoleCommand { + constructor( + private readonly organizationRepo: OrganizationRepository, + private readonly memberRepo: MemberRepository, + private readonly organizationMemberRepo: OrganizationMemberRepository + ) {} + + async execute( + request: ChangeMemberRoleRequest + ): Promise { + // 1. 조직 존재 확인 + const organization = await this.organizationRepo.findBySlug( + request.organizationSlug + ); + if (!organization) { + throw new Error(`Organization "${request.organizationSlug}" not found`); + } + + // 2. 멤버 존재 확인 + const member = await this.memberRepo.findByDiscordId( + request.memberDiscordId + ); + if (!member) { + throw new Error( + `Member with Discord ID ${request.memberDiscordId} not found` + ); + } + + // 3. 조직원 존재 확인 + const organizationMember = + await this.organizationMemberRepo.findByOrganizationAndMember( + organization.id, + member.id + ); + + if (!organizationMember) { + throw new Error( + `Member is not part of organization "${request.organizationSlug}"` + ); + } + + // 4. 역할 변경 + organizationMember.changeRole(request.newRole); + + // 5. 저장 + await this.organizationMemberRepo.save(organizationMember); + + return { + organizationMember, + organization, + member, + }; + } +} diff --git a/apps/server/src/application/commands/index.ts b/apps/server/src/application/commands/index.ts index b24a4a2..07d2ac6 100644 --- a/apps/server/src/application/commands/index.ts +++ b/apps/server/src/application/commands/index.ts @@ -9,3 +9,4 @@ export * from './add-member-to-organization.command'; export * from './update-member-status.command'; export * from './join-organization.command'; export * from './join-generation.command'; +export * from './change-member-role.command'; From 901deb66fc03384463443561d6fa29c65323e24f Mon Sep 17 00:00:00 2001 From: BBAKJUN Date: Wed, 7 Jan 2026 15:36:59 +0900 Subject: [PATCH 3/4] feat(graphql): load members in organizations query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load organization members when querying organizations. - Add helper function `mapToGqlOrganizationMember` for transformation - Update `organizations` query to load all members for each organization - Update `activeOrganizations` query to load members - Update `organization(slug)` query to load members - Previously `organizations.members` returned null, now returns member list 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../resolvers/organization.resolver.ts | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/server/src/presentation/graphql/resolvers/organization.resolver.ts b/apps/server/src/presentation/graphql/resolvers/organization.resolver.ts index 3dc7722..0382f9b 100644 --- a/apps/server/src/presentation/graphql/resolvers/organization.resolver.ts +++ b/apps/server/src/presentation/graphql/resolvers/organization.resolver.ts @@ -2,14 +2,20 @@ import { GetOrganizationQuery } from '@/application'; import { DrizzleOrganizationRepository } from '@/infrastructure/persistence/drizzle'; -import { GqlOrganization } from '../types'; +import { DrizzleOrganizationMemberRepository } from '@/infrastructure/persistence/drizzle'; +import { DrizzleMemberRepository } from '@/infrastructure/persistence/drizzle'; +import { GqlOrganization, GqlOrganizationMember } from '../types'; import { domainToGraphqlOrganization } from '../mappers'; +import { OrganizationMember } from '@/domain/organization-member/organization-member.domain'; +import { Member } from '@/domain/member/member.domain'; // ======================================== // Repository Instances // ======================================== const organizationRepo = new DrizzleOrganizationRepository(); +const organizationMemberRepo = new DrizzleOrganizationMemberRepository(); +const memberRepo = new DrizzleMemberRepository(); // ======================================== // Query Instances @@ -21,25 +27,69 @@ const getOrganizationQuery = new GetOrganizationQuery(organizationRepo); // Resolvers // ======================================== +// Helper 함수: OrganizationMember를 GqlOrganizationMember로 변환 +async function mapToGqlOrganizationMember( + organizationMember: OrganizationMember +): Promise { + const member = await memberRepo.findById(organizationMember.memberId); + if (!member) { + throw new Error(`Member ${organizationMember.memberId.value} not found`); + } + + return new GqlOrganizationMember(organizationMember, { + id: member.id.value, + github: member.githubUsername?.value ?? '', + discordId: member.discordId.value, + name: member.name.value, + createdAt: member.createdAt.toISOString(), + }); +} + export const organizationQueries = { // 조직 전체 조회 organizations: async (): Promise => { const orgs = await organizationRepo.findAll(); - return orgs.map((org) => domainToGraphqlOrganization(org)); + const results = await Promise.all( + orgs.map(async (org) => { + const organizationMembers = + await organizationMemberRepo.findByOrganization(org.id); + const members = await Promise.all( + organizationMembers.map(mapToGqlOrganizationMember) + ); + return domainToGraphqlOrganization(org, members); + }) + ); + return results; }, // 활성화된 조직 조회 activeOrganizations: async (): Promise => { const orgs = await organizationRepo.findActive(); - return orgs.map((org) => domainToGraphqlOrganization(org)); + const results = await Promise.all( + orgs.map(async (org) => { + const organizationMembers = + await organizationMemberRepo.findByOrganization(org.id); + const members = await Promise.all( + organizationMembers.map(mapToGqlOrganizationMember) + ); + return domainToGraphqlOrganization(org, members); + }) + ); + return results; }, // 조직 단건 조회 (slug로) organization: async (slug: string): Promise => { const result = await getOrganizationQuery.execute({ slug }); - return result.organization - ? domainToGraphqlOrganization(result.organization) - : null; + if (!result.organization) return null; + + const organizationMembers = + await organizationMemberRepo.findByOrganization(result.organization.id); + const members = await Promise.all( + organizationMembers.map(mapToGqlOrganizationMember) + ); + + return domainToGraphqlOrganization(result.organization, members); }, }; From c9b8ad196f3ea127fec9aeda2c50193c5b891003 Mon Sep 17 00:00:00 2001 From: BBAKJUN Date: Wed, 7 Jan 2026 15:37:16 +0900 Subject: [PATCH 4/4] feat(graphql): load members in generations query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load generation members when querying generations. - Add helper function `mapToGqlGenerationMember` for transformation - Add `generationMemberRepo` and `memberRepo` instances - Update `generations` query to load all members for each generation - Update `generation(id)` query to load members - Update `activeGeneration` query to load members - Update `addGeneration` mutation to load members after creation - Previously `generations.members` returned null, now returns member list 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../graphql/resolvers/generation.resolver.ts | 65 +++++++++++++++++-- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/apps/server/src/presentation/graphql/resolvers/generation.resolver.ts b/apps/server/src/presentation/graphql/resolvers/generation.resolver.ts index 8b566bf..dd10840 100644 --- a/apps/server/src/presentation/graphql/resolvers/generation.resolver.ts +++ b/apps/server/src/presentation/graphql/resolvers/generation.resolver.ts @@ -9,9 +9,13 @@ import { OrganizationId } from '@/domain/organization/organization.domain'; import { DrizzleGenerationRepository, DrizzleOrganizationRepository, + DrizzleGenerationMemberRepository, + DrizzleMemberRepository, } from '@/infrastructure/persistence/drizzle'; import { domainToGraphqlOrganization } from '../mappers'; -import { GqlGeneration } from '../types'; +import { GqlGeneration, GqlGenerationMember } from '../types'; +import { GenerationMember } from '@/domain/generation-member/generation-member.domain'; +import { Member } from '@/domain/member/member.domain'; // ======================================== // Repository Instances @@ -19,6 +23,8 @@ import { GqlGeneration } from '../types'; const generationRepo = new DrizzleGenerationRepository(); const organizationRepo = new DrizzleOrganizationRepository(); +const generationMemberRepo = new DrizzleGenerationMemberRepository(); +const memberRepo = new DrizzleMemberRepository(); // ======================================== // Query & Command Instances @@ -35,7 +41,25 @@ const createGenerationCommand = new CreateGenerationCommand( // Helper Functions // ======================================== -async function loadGenerationWithOrganization( +// Helper 함수: GenerationMember를 GqlGenerationMember로 변환 +async function mapToGqlGenerationMember( + generationMember: GenerationMember +): Promise { + const member = await memberRepo.findById(generationMember.memberId); + if (!member) { + throw new Error(`Member ${generationMember.memberId.value} not found`); + } + + return new GqlGenerationMember(generationMember, { + id: member.id.value, + github: member.githubUsername?.value ?? '', + discordId: member.discordId.value, + name: member.name.value, + createdAt: member.createdAt.toISOString(), + }); +} + +async function loadGenerationWithOrganizationAndMembers( generation: Awaited> ): Promise { if (!generation) return null; @@ -43,9 +67,18 @@ async function loadGenerationWithOrganization( const organization = await organizationRepo.findById( OrganizationId.create(generation.organizationId) ); + + const generationMembers = + await generationMemberRepo.findByGeneration(generation.id.value); + const members = await Promise.all( + generationMembers.map(mapToGqlGenerationMember) + ); + return new GqlGeneration( generation, - organization ? domainToGraphqlOrganization(organization) : undefined + organization ? domainToGraphqlOrganization(organization) : undefined, + undefined, + members ); } @@ -62,9 +95,18 @@ export const generationQueries = { const organization = await organizationRepo.findById( OrganizationId.create(gen.organizationId) ); + + const generationMembers = + await generationMemberRepo.findByGeneration(gen.id.value); + const members = await Promise.all( + generationMembers.map(mapToGqlGenerationMember) + ); + return new GqlGeneration( gen, - organization ? domainToGraphqlOrganization(organization) : undefined + organization ? domainToGraphqlOrganization(organization) : undefined, + undefined, + members ); }) ); @@ -74,13 +116,13 @@ export const generationQueries = { // 기수 단건 조회 generation: async (id: number): Promise => { const generation = await getGenerationByIdQuery.execute(id); - return loadGenerationWithOrganization(generation); + return loadGenerationWithOrganizationAndMembers(generation); }, // 활성화된 기수 조회 activeGeneration: async (): Promise => { const generation = await generationRepo.findActive(); - return loadGenerationWithOrganization(generation); + return loadGenerationWithOrganizationAndMembers(generation); }, }; @@ -99,9 +141,18 @@ export const generationMutations = { const organization = await organizationRepo.findById( OrganizationId.create(result.generation.organizationId) ); + + const generationMembers = + await generationMemberRepo.findByGeneration(result.generation.id.value); + const members = await Promise.all( + generationMembers.map(mapToGqlGenerationMember) + ); + return new GqlGeneration( result.generation, - organization ? domainToGraphqlOrganization(organization) : undefined + organization ? domainToGraphqlOrganization(organization) : undefined, + undefined, + members ); }, };