From e9ae82f51f43186340b0fe8cd9b0be505c82abf2 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sun, 14 Dec 2025 13:47:42 -0800 Subject: [PATCH 01/22] feat: add chapter name filtering to nonprofits query --- .../graphql/resolvers/nonprofits.resolvers.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/api/graphql/resolvers/nonprofits.resolvers.ts b/src/api/graphql/resolvers/nonprofits.resolvers.ts index f38ed8a..0048236 100644 --- a/src/api/graphql/resolvers/nonprofits.resolvers.ts +++ b/src/api/graphql/resolvers/nonprofits.resolvers.ts @@ -6,6 +6,7 @@ import { getNonprofitsWithFilters, updateNonprofit, updateNonprofitSchema, + getChapterIdsByNames, } from '../../../core'; import { GraphQLError } from 'graphql'; import { z } from 'zod'; @@ -14,24 +15,42 @@ import type { NonprofitSortOption, } from '../../../core/services/nonprofits.service'; -// Infer TypeScript types directly from your Zod schemas type CreateNonprofitInput = z.infer; type UpdateNonprofitInput = z.infer; interface NonprofitsQueryArgs { chapterIds?: string[]; + chapterNames?: string[]; statuses?: StatusType[]; sort?: NonprofitSortOption[]; } export const nonprofitResolvers = { Query: { - nonprofits: ( + nonprofits: async ( _parent: unknown, - { chapterIds, statuses, sort }: NonprofitsQueryArgs + { chapterIds, chapterNames, statuses, sort }: NonprofitsQueryArgs ) => { - return getNonprofitsWithFilters({ chapterIds, statuses, sort }); + let resolvedChapterIds = chapterIds; + + // If frontend passes chapterNames, convert them to ids + if ( + (!resolvedChapterIds || resolvedChapterIds.length === 0) && + chapterNames?.length + ) { + resolvedChapterIds = await getChapterIdsByNames(chapterNames); + + // If user selected chapters but none matched, return empty list + if (!resolvedChapterIds || resolvedChapterIds.length === 0) return []; + } + + return getNonprofitsWithFilters({ + chapterIds: resolvedChapterIds, + statuses, + sort, + }); }, + nonprofit: async (_parent: unknown, { id }: { id: string }) => { const nonprofit = await getNonprofitById(id); if (!nonprofit) { @@ -48,6 +67,7 @@ export const nonprofitResolvers = { return nonprofit; }, }, + Mutation: { createNonprofit: ( _parent: unknown, @@ -56,6 +76,7 @@ export const nonprofitResolvers = { const validatedInput = createNonprofitSchema.parse(input); return createNonprofit(validatedInput); }, + updateNonprofit: ( _parent: unknown, { @@ -66,6 +87,7 @@ export const nonprofitResolvers = { const validatedInput = updateNonprofitSchema.parse(input); return updateNonprofit(nonprofit_id, validatedInput); }, + deleteNonprofit: (_parent: unknown, { id }: { id: string }) => { return deleteNonprofit(id); }, From 69c72c6a052b121d2bf2a446ca35a33f92f4f676 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sun, 14 Dec 2025 13:47:48 -0800 Subject: [PATCH 02/22] feat: add chapter names filtering to nonprofits query --- src/api/graphql/schemas/nonprofits.schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/graphql/schemas/nonprofits.schema.ts b/src/api/graphql/schemas/nonprofits.schema.ts index 46e71eb..7feda8d 100644 --- a/src/api/graphql/schemas/nonprofits.schema.ts +++ b/src/api/graphql/schemas/nonprofits.schema.ts @@ -21,6 +21,7 @@ export const nonprofitSchemaString = ` type Query { nonprofits( chapterIds: [ID!] + chapterNames: [String!] statuses: [StatusType!] sort: [NonprofitSortOption!] ): [Nonprofit!]! From 95c6306694187f02190999d684a21e9d7af8e4ed Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sun, 14 Dec 2025 13:47:53 -0800 Subject: [PATCH 03/22] feat: add function to retrieve chapter IDs by names --- src/core/services/nonprofits.service.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index 0b65630..e933788 100644 --- a/src/core/services/nonprofits.service.ts +++ b/src/core/services/nonprofits.service.ts @@ -519,3 +519,15 @@ export async function deleteNonprofit(id: string): Promise { throw new DatabaseError('Failed to delete nonprofit'); } } + +export async function getChapterIdsByNames(names: string[]): Promise { + const cleaned = [...new Set(names.map((n) => n.trim()).filter(Boolean))]; + if (cleaned.length === 0) return []; + + const chapters = await prisma.chapters.findMany({ + where: { name: { in: cleaned } }, + select: { chapter_id: true }, + }); + + return chapters.map((c) => c.chapter_id); +} From 6a2acdd608aa9896f6e5a84b6ffddee1d9e974c4 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Thu, 18 Dec 2025 14:26:58 -0800 Subject: [PATCH 04/22] feat: add nonprofitChapterProjects query to retrieve projects by nonprofit and chapter IDs --- .../graphql/resolvers/nonprofits.resolvers.ts | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/api/graphql/resolvers/nonprofits.resolvers.ts b/src/api/graphql/resolvers/nonprofits.resolvers.ts index 0048236..ca384f9 100644 --- a/src/api/graphql/resolvers/nonprofits.resolvers.ts +++ b/src/api/graphql/resolvers/nonprofits.resolvers.ts @@ -14,6 +14,8 @@ import type { StatusType, NonprofitSortOption, } from '../../../core/services/nonprofits.service'; +import { prisma } from '../../../config/database'; +import { Prisma, status_type } from '@prisma/client'; type CreateNonprofitInput = z.infer; type UpdateNonprofitInput = z.infer; @@ -25,6 +27,12 @@ interface NonprofitsQueryArgs { sort?: NonprofitSortOption[]; } +interface NonprofitChapterProjectsArgs { + nonprofitIds?: string[]; + chapterIds?: string[]; + projectStatuses?: StatusType[]; +} + export const nonprofitResolvers = { Query: { nonprofits: async ( @@ -33,14 +41,11 @@ export const nonprofitResolvers = { ) => { let resolvedChapterIds = chapterIds; - // If frontend passes chapterNames, convert them to ids if ( (!resolvedChapterIds || resolvedChapterIds.length === 0) && chapterNames?.length ) { resolvedChapterIds = await getChapterIdsByNames(chapterNames); - - // If user selected chapters but none matched, return empty list if (!resolvedChapterIds || resolvedChapterIds.length === 0) return []; } @@ -66,6 +71,35 @@ export const nonprofitResolvers = { } return nonprofit; }, + + nonprofitChapterProjects: async ( + _parent: unknown, + { + nonprofitIds, + chapterIds, + projectStatuses, + }: NonprofitChapterProjectsArgs + ) => { + const where: Prisma.nonprofit_chapter_projectWhereInput = {}; + + if (nonprofitIds?.length) where.nonprofit_id = { in: nonprofitIds }; + if (chapterIds?.length) where.chapter_id = { in: chapterIds }; + + if (projectStatuses?.length) { + where.project_status = { + in: projectStatuses.map((s) => + s === 'ACTIVE' ? status_type.ACTIVE : status_type.INACTIVE + ), + }; + } + + const rows = await prisma.nonprofit_chapter_project.findMany({ + where, + orderBy: { created_at: 'desc' }, + }); + + return rows ?? []; + }, }, Mutation: { From cd1b889c7f80c709a3975585856d3fc31c916dda Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Thu, 18 Dec 2025 14:27:02 -0800 Subject: [PATCH 05/22] feat: add NonprofitChapterProject type and query for chapter projects --- src/api/graphql/schemas/nonprofits.schema.ts | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/api/graphql/schemas/nonprofits.schema.ts b/src/api/graphql/schemas/nonprofits.schema.ts index 7feda8d..ebc4b4f 100644 --- a/src/api/graphql/schemas/nonprofits.schema.ts +++ b/src/api/graphql/schemas/nonprofits.schema.ts @@ -18,6 +18,21 @@ export const nonprofitSchemaString = ` status: StatusType! } + type NonprofitChapterProject { + nonprofit_chapter_project_id: ID! + nonprofit_id: ID! + chapter_id: ID + project_id: ID! + project_contact_id: ID! + collab_contact_id: ID! + start_date: String! + end_date: String + notes: String + project_status: StatusType! + created_at: String! + updated_at: String! + } + type Query { nonprofits( chapterIds: [ID!] @@ -25,7 +40,14 @@ export const nonprofitSchemaString = ` statuses: [StatusType!] sort: [NonprofitSortOption!] ): [Nonprofit!]! + nonprofit(id: ID!): Nonprofit + + nonprofitChapterProjects( + nonprofitIds: [ID!] + chapterIds: [ID!] + projectStatuses: [StatusType!] + ): [NonprofitChapterProject!]! } input CreateNonprofitInput { From 43f65c61be3c02a7ad4971dad3f4e7b429cfa7be Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Thu, 18 Dec 2025 14:27:05 -0800 Subject: [PATCH 06/22] feat: implement Docker-based test database setup for isolated testing --- src/core/services/nonprofits.service.ts | 66 ++++++++++++++++--------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index e933788..85130d0 100644 --- a/src/core/services/nonprofits.service.ts +++ b/src/core/services/nonprofits.service.ts @@ -7,7 +7,7 @@ import { DatabaseError, } from '../../middleware/error.middleware'; import { z } from 'zod'; -import { Prisma } from '@prisma/client'; +import { Prisma, status_type } from '@prisma/client'; type CreateNonprofitInput = z.infer; type UpdateNonprofitInput = z.infer; @@ -133,20 +133,49 @@ export async function getNonprofitsWithFilters( logger.info('Fetching nonprofits with filters', { filters }); - // Build Prisma where clause - const where: Prisma.nonprofitsWhereInput = {}; + const now = new Date(); + const activeProjectWhere: Prisma.nonprofit_chapter_projectWhereInput = { + project_status: status_type.ACTIVE, + OR: [{ end_date: null }, { end_date: { gt: now } }], + }; + + const andConditions: Prisma.nonprofitsWhereInput[] = []; + + // Chapter filter if (chapterIds && chapterIds.length > 0) { - where.nonprofit_chapter_project = { - some: { - chapter_id: { - in: chapterIds, + andConditions.push({ + nonprofit_chapter_project: { + some: { + chapter_id: { in: chapterIds }, }, }, - }; + }); } - // Fetch nonprofits with related projects + // Status filter - ACTIVE / INACTIVE + if (statuses && statuses.length > 0) { + const wantsActive = statuses.includes('ACTIVE'); + const wantsInactive = statuses.includes('INACTIVE'); + + if (wantsActive && !wantsInactive) { + andConditions.push({ + nonprofit_chapter_project: { + some: activeProjectWhere, + }, + }); + } else if (wantsInactive && !wantsActive) { + andConditions.push({ + nonprofit_chapter_project: { + none: activeProjectWhere, + }, + }); + } + } + + const where: Prisma.nonprofitsWhereInput = + andConditions.length > 0 ? { AND: andConditions } : {}; + const nonprofits = await prisma.nonprofits.findMany({ where, include: { @@ -159,10 +188,9 @@ export async function getNonprofitsWithFilters( }, }, }, - orderBy: { created_at: 'desc' }, // Default ordering + orderBy: { created_at: 'desc' }, }); - // Enrich nonprofits with derived fields const enrichedNonprofits: EnrichedNonprofit[] = nonprofits.map((np) => { const status = deriveNonprofitStatus(np.nonprofit_chapter_project); const latestStartDate = getLatestStartDate(np.nonprofit_chapter_project); @@ -181,25 +209,17 @@ export async function getNonprofitsWithFilters( }; }); - // Apply status filter - let filteredNonprofits = enrichedNonprofits; - if (statuses && statuses.length > 0) { - filteredNonprofits = enrichedNonprofits.filter((np) => - statuses.includes(np.status) - ); - } - - // Apply sorting + let result = enrichedNonprofits; if (sort && sort.length > 0) { const comparator = createNonprofitComparator(sort); - filteredNonprofits.sort(comparator); + result = [...enrichedNonprofits].sort(comparator); } logger.info('Successfully retrieved and filtered nonprofits', { - count: filteredNonprofits.length, + count: result.length, }); - return filteredNonprofits; + return result; } catch (error) { logger.error('Failed to fetch nonprofits with filters', { error: error instanceof Error ? error.message : 'Unknown error', From 23afe67ae9b2c80cc2c780ba17a255a418bd03bd Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Thu, 18 Dec 2025 14:27:21 -0800 Subject: [PATCH 07/22] ran prettier formatting --- README.md | 4 ++-- docker-compose.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aeef139..0c383b9 100644 --- a/README.md +++ b/README.md @@ -230,8 +230,8 @@ Command to enter in terminal to access tables: - docker compose exec -T test-db psql -U postgres -d test_db - \dt -- SQL commands such as (SELECT * FROM volunteers;) +- SQL commands such as (SELECT \* FROM volunteers;) To pause test, insert this: await new Promise(() => {}); -It helps you see when data is created in the local database! \ No newline at end of file +It helps you see when data is created in the local database! diff --git a/docker-compose.yml b/docker-compose.yml index 54e7647..687474f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,12 +7,12 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_db ports: - - "5433:5432" # Map to port 5433 to avoid conflicts with local PostgreSQL + - '5433:5432' # Map to port 5433 to avoid conflicts with local PostgreSQL volumes: - test-db-data:/var/lib/postgresql/data - - ./db-init:/docker-entrypoint-initdb.d # Initialization scripts + - ./db-init:/docker-entrypoint-initdb.d # Initialization scripts healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d test_db"] + test: ['CMD-SHELL', 'pg_isready -U postgres -d test_db'] interval: 5s timeout: 5s retries: 5 From 7807039099fa4c240c4e59611d9963a2e1b79a14 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Thu, 18 Dec 2025 14:52:20 -0800 Subject: [PATCH 08/22] feat: enhance filtering logic for nonprofits with dynamic conditions --- src/core/services/nonprofits.service.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index 85130d0..57aebaf 100644 --- a/src/core/services/nonprofits.service.ts +++ b/src/core/services/nonprofits.service.ts @@ -146,9 +146,7 @@ export async function getNonprofitsWithFilters( if (chapterIds && chapterIds.length > 0) { andConditions.push({ nonprofit_chapter_project: { - some: { - chapter_id: { in: chapterIds }, - }, + some: { chapter_id: { in: chapterIds } }, }, }); } @@ -173,8 +171,12 @@ export async function getNonprofitsWithFilters( } } - const where: Prisma.nonprofitsWhereInput = - andConditions.length > 0 ? { AND: andConditions } : {}; + let where: Prisma.nonprofitsWhereInput = {}; + if (andConditions.length === 1) { + where = andConditions[0]; + } else if (andConditions.length > 1) { + where = { AND: andConditions }; + } const nonprofits = await prisma.nonprofits.findMany({ where, @@ -210,9 +212,13 @@ export async function getNonprofitsWithFilters( }); let result = enrichedNonprofits; + if (statuses && statuses.length > 0) { + result = enrichedNonprofits.filter((np) => statuses.includes(np.status)); + } + if (sort && sort.length > 0) { const comparator = createNonprofitComparator(sort); - result = [...enrichedNonprofits].sort(comparator); + result = [...result].sort(comparator); } logger.info('Successfully retrieved and filtered nonprofits', { From 4f57c7282657e450336fc4b3dc8563beed751f39 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 27 Dec 2025 11:42:51 -0800 Subject: [PATCH 09/22] feat: add support for pagination in nonprofit queries --- src/core/services/nonprofits.service.ts | 66 ++++++++++++------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index 57aebaf..2bcb3f8 100644 --- a/src/core/services/nonprofits.service.ts +++ b/src/core/services/nonprofits.service.ts @@ -1,4 +1,3 @@ -// src/core/services/nonprofits.service.ts import { prisma } from '../../config/database'; import { createNonprofitSchema, updateNonprofitSchema } from '../validators'; import { logger } from '../../config/logger'; @@ -140,46 +139,44 @@ export async function getNonprofitsWithFilters( OR: [{ end_date: null }, { end_date: { gt: now } }], }; - const andConditions: Prisma.nonprofitsWhereInput[] = []; + const ncpWhere: Prisma.nonprofit_chapter_projectWhereInput = {}; - // Chapter filter - if (chapterIds && chapterIds.length > 0) { - andConditions.push({ - nonprofit_chapter_project: { - some: { chapter_id: { in: chapterIds } }, - }, - }); + if (chapterIds?.length) { + ncpWhere.chapter_id = { in: chapterIds }; } - // Status filter - ACTIVE / INACTIVE - if (statuses && statuses.length > 0) { + if (statuses?.length) { const wantsActive = statuses.includes('ACTIVE'); const wantsInactive = statuses.includes('INACTIVE'); + // Only ACTIVE if (wantsActive && !wantsInactive) { - andConditions.push({ - nonprofit_chapter_project: { - some: activeProjectWhere, - }, - }); - } else if (wantsInactive && !wantsActive) { - andConditions.push({ - nonprofit_chapter_project: { - none: activeProjectWhere, - }, - }); + Object.assign(ncpWhere, activeProjectWhere); + } + + // Only INACTIVE + if (wantsInactive && !wantsActive) { + ncpWhere.NOT = activeProjectWhere; } - } - let where: Prisma.nonprofitsWhereInput = {}; - if (andConditions.length === 1) { - where = andConditions[0]; - } else if (andConditions.length > 1) { - where = { AND: andConditions }; } + const joinRows = await prisma.nonprofit_chapter_project.findMany({ + where: Object.keys(ncpWhere).length ? ncpWhere : undefined, + select: { nonprofit_id: true }, + }); + + // if user provided any filter that relies on join table and have nothing, return [] + const usedJoinFilters = + (chapterIds && chapterIds.length > 0) || (statuses && statuses.length > 0); + + if (usedJoinFilters && joinRows.length === 0) return []; + + const nonprofitIds = [...new Set(joinRows.map((r) => r.nonprofit_id))]; + + // fetch nonprofits by those ids (or fetch all if no join filters) const nonprofits = await prisma.nonprofits.findMany({ - where, + where: usedJoinFilters ? { nonprofit_id: { in: nonprofitIds } } : undefined, include: { nonprofit_chapter_project: { select: { @@ -212,11 +209,12 @@ export async function getNonprofitsWithFilters( }); let result = enrichedNonprofits; - if (statuses && statuses.length > 0) { - result = enrichedNonprofits.filter((np) => statuses.includes(np.status)); + if (statuses?.length) { + result = result.filter((np) => statuses.includes(np.status)); } - if (sort && sort.length > 0) { + // sorting + if (sort?.length) { const comparator = createNonprofitComparator(sort); result = [...result].sort(comparator); } @@ -328,8 +326,8 @@ export async function createNonprofit( const data: Prisma.nonprofitsUncheckedCreateInput = { name, - mission, // required string - contact_id, // required string + mission, + contact_id, ...(website === undefined ? {} : { website }), // include if not undefined (can be null) ...(location_id === undefined ? {} : { location_id }), // include if not undefined (can be null) }; From 3929bfdc5bde1cb15b46b14c29c4063d220caec8 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 27 Dec 2025 11:42:58 -0800 Subject: [PATCH 10/22] feat: add NonprofitChapterInfo type and update Nonprofit schema to include chapter status --- src/api/graphql/schemas/nonprofits.schema.ts | 30 ++++++-------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/api/graphql/schemas/nonprofits.schema.ts b/src/api/graphql/schemas/nonprofits.schema.ts index ebc4b4f..bc1a50e 100644 --- a/src/api/graphql/schemas/nonprofits.schema.ts +++ b/src/api/graphql/schemas/nonprofits.schema.ts @@ -6,6 +6,12 @@ export const nonprofitSchemaString = ` STATUS } + type NonprofitChapterInfo { + chapter_id: ID! + chapter_name: String! + project_status: StatusType! + } + type Nonprofit { nonprofit_id: ID! name: String! @@ -16,21 +22,9 @@ export const nonprofitSchemaString = ` created_at: String! updated_at: String! status: StatusType! - } - type NonprofitChapterProject { - nonprofit_chapter_project_id: ID! - nonprofit_id: ID! - chapter_id: ID - project_id: ID! - project_contact_id: ID! - collab_contact_id: ID! - start_date: String! - end_date: String - notes: String - project_status: StatusType! - created_at: String! - updated_at: String! + # chapter + project status info (pulled internally from nonprofit_chapter_project) + chapters: [NonprofitChapterInfo!]! } type Query { @@ -42,12 +36,6 @@ export const nonprofitSchemaString = ` ): [Nonprofit!]! nonprofit(id: ID!): Nonprofit - - nonprofitChapterProjects( - nonprofitIds: [ID!] - chapterIds: [ID!] - projectStatuses: [StatusType!] - ): [NonprofitChapterProject!]! } input CreateNonprofitInput { @@ -71,4 +59,4 @@ export const nonprofitSchemaString = ` updateNonprofit(nonprofit_id: ID!, input: UpdateNonprofitInput!): Nonprofit! deleteNonprofit(id: ID!): Nonprofit } -`; +`; \ No newline at end of file From 4ebcc754280467b4d6948f05230700efb19d1c55 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 27 Dec 2025 12:36:34 -0800 Subject: [PATCH 11/22] feat: refactor nonprofit resolvers to simplify logic and improve performance --- .../graphql/resolvers/nonprofits.resolvers.ts | 54 ++++++++----------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/src/api/graphql/resolvers/nonprofits.resolvers.ts b/src/api/graphql/resolvers/nonprofits.resolvers.ts index ca384f9..3c78b16 100644 --- a/src/api/graphql/resolvers/nonprofits.resolvers.ts +++ b/src/api/graphql/resolvers/nonprofits.resolvers.ts @@ -15,7 +15,7 @@ import type { NonprofitSortOption, } from '../../../core/services/nonprofits.service'; import { prisma } from '../../../config/database'; -import { Prisma, status_type } from '@prisma/client'; +import { status_type } from '@prisma/client'; type CreateNonprofitInput = z.infer; type UpdateNonprofitInput = z.infer; @@ -27,12 +27,6 @@ interface NonprofitsQueryArgs { sort?: NonprofitSortOption[]; } -interface NonprofitChapterProjectsArgs { - nonprofitIds?: string[]; - chapterIds?: string[]; - projectStatuses?: StatusType[]; -} - export const nonprofitResolvers = { Query: { nonprofits: async ( @@ -46,7 +40,7 @@ export const nonprofitResolvers = { chapterNames?.length ) { resolvedChapterIds = await getChapterIdsByNames(chapterNames); - if (!resolvedChapterIds || resolvedChapterIds.length === 0) return []; + if (!resolvedChapterIds.length) return []; } return getNonprofitsWithFilters({ @@ -71,34 +65,32 @@ export const nonprofitResolvers = { } return nonprofit; }, + }, - nonprofitChapterProjects: async ( - _parent: unknown, - { - nonprofitIds, - chapterIds, - projectStatuses, - }: NonprofitChapterProjectsArgs - ) => { - const where: Prisma.nonprofit_chapter_projectWhereInput = {}; - - if (nonprofitIds?.length) where.nonprofit_id = { in: nonprofitIds }; - if (chapterIds?.length) where.chapter_id = { in: chapterIds }; - - if (projectStatuses?.length) { - where.project_status = { - in: projectStatuses.map((s) => - s === 'ACTIVE' ? status_type.ACTIVE : status_type.INACTIVE - ), - }; - } - + Nonprofit: { + chapters: async (parent: { nonprofit_id: string }) => { const rows = await prisma.nonprofit_chapter_project.findMany({ - where, + where: { nonprofit_id: parent.nonprofit_id }, + select: { + project_status: true, + chapters: { + select: { + chapter_id: true, + name: true, + }, + }, + }, orderBy: { created_at: 'desc' }, }); - return rows ?? []; + return rows + .filter((r) => r.chapters !== null) + .map((r) => ({ + chapter_id: r.chapters!.chapter_id, + chapter_name: r.chapters!.name, + project_status: + r.project_status === status_type.ACTIVE ? 'ACTIVE' : 'INACTIVE', + })); }, }, From 5d128b43aa6f40da6523e03cdbe447cd6403379f Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 27 Dec 2025 12:36:37 -0800 Subject: [PATCH 12/22] fix: add missing newline at end of nonprofit schema file --- src/api/graphql/schemas/nonprofits.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/graphql/schemas/nonprofits.schema.ts b/src/api/graphql/schemas/nonprofits.schema.ts index bc1a50e..8045d1e 100644 --- a/src/api/graphql/schemas/nonprofits.schema.ts +++ b/src/api/graphql/schemas/nonprofits.schema.ts @@ -59,4 +59,4 @@ export const nonprofitSchemaString = ` updateNonprofit(nonprofit_id: ID!, input: UpdateNonprofitInput!): Nonprofit! deleteNonprofit(id: ID!): Nonprofit } -`; \ No newline at end of file +`; From 7a791c3ff07cf607f7d7da0c44805e5de47ad3a8 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 27 Dec 2025 12:36:40 -0800 Subject: [PATCH 13/22] feat: enhance nonprofit filtering logic with dynamic join conditions --- src/core/services/nonprofits.service.ts | 58 +++++++++++++------------ 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index 2bcb3f8..4a4a5a2 100644 --- a/src/core/services/nonprofits.service.ts +++ b/src/core/services/nonprofits.service.ts @@ -139,44 +139,49 @@ export async function getNonprofitsWithFilters( OR: [{ end_date: null }, { end_date: { gt: now } }], }; - const ncpWhere: Prisma.nonprofit_chapter_projectWhereInput = {}; + const hasJoinFilters = + (chapterIds && chapterIds.length > 0) || + (statuses && statuses.length > 0); - if (chapterIds?.length) { - ncpWhere.chapter_id = { in: chapterIds }; - } + let nonprofitIdFilter: Prisma.nonprofitsWhereInput | undefined; - if (statuses?.length) { - const wantsActive = statuses.includes('ACTIVE'); - const wantsInactive = statuses.includes('INACTIVE'); + if (hasJoinFilters) { + const ncpWhere: Prisma.nonprofit_chapter_projectWhereInput = {}; - // Only ACTIVE - if (wantsActive && !wantsInactive) { - Object.assign(ncpWhere, activeProjectWhere); + if (chapterIds?.length) { + ncpWhere.chapter_id = { in: chapterIds }; } - // Only INACTIVE - if (wantsInactive && !wantsActive) { - ncpWhere.NOT = activeProjectWhere; - } + if (statuses?.length) { + const wantsActive = statuses.includes('ACTIVE'); + const wantsInactive = statuses.includes('INACTIVE'); - } + // Only ACTIVE + if (wantsActive && !wantsInactive) { + Object.assign(ncpWhere, activeProjectWhere); + } + // Only INACTIVE + else if (wantsInactive && !wantsActive) { + ncpWhere.NOT = activeProjectWhere; + } + } - const joinRows = await prisma.nonprofit_chapter_project.findMany({ - where: Object.keys(ncpWhere).length ? ncpWhere : undefined, - select: { nonprofit_id: true }, - }); + const joinRows = await prisma.nonprofit_chapter_project.findMany({ + where: ncpWhere, + select: { nonprofit_id: true }, + }); - // if user provided any filter that relies on join table and have nothing, return [] - const usedJoinFilters = - (chapterIds && chapterIds.length > 0) || (statuses && statuses.length > 0); + if (joinRows.length === 0) return []; - if (usedJoinFilters && joinRows.length === 0) return []; + const nonprofitIds = [...new Set(joinRows.map((r) => r.nonprofit_id))]; - const nonprofitIds = [...new Set(joinRows.map((r) => r.nonprofit_id))]; + nonprofitIdFilter = { + nonprofit_id: { in: nonprofitIds }, + }; + } - // fetch nonprofits by those ids (or fetch all if no join filters) const nonprofits = await prisma.nonprofits.findMany({ - where: usedJoinFilters ? { nonprofit_id: { in: nonprofitIds } } : undefined, + where: nonprofitIdFilter, include: { nonprofit_chapter_project: { select: { @@ -213,7 +218,6 @@ export async function getNonprofitsWithFilters( result = result.filter((np) => statuses.includes(np.status)); } - // sorting if (sort?.length) { const comparator = createNonprofitComparator(sort); result = [...result].sort(comparator); From 9c5e3686630e30984f13c5ca1d552433e61b4a04 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 27 Dec 2025 12:36:43 -0800 Subject: [PATCH 14/22] feat: add support for advanced filtering options in nonprofit queries --- .../unit/services/nonprofits.service.test.ts | 77 +++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/tests/unit/services/nonprofits.service.test.ts b/tests/unit/services/nonprofits.service.test.ts index 1dcd694..84a76e4 100644 --- a/tests/unit/services/nonprofits.service.test.ts +++ b/tests/unit/services/nonprofits.service.test.ts @@ -1,13 +1,3 @@ -import { - getAllNonprofits, - getNonprofitById, - createNonprofit, - updateNonprofit, - deleteNonprofit, - getNonprofitsWithFilters, -} from '../../../src/core'; -import { prisma } from '../../../src/config/database'; - // Mock prisma client jest.mock('../../../src/config/database', () => ({ prisma: { @@ -18,9 +8,25 @@ jest.mock('../../../src/config/database', () => ({ update: jest.fn(), delete: jest.fn(), }, + nonprofit_chapter_project: { + findMany: jest.fn(), + }, + chapters: { + findMany: jest.fn(), + }, }, })); +import { + getAllNonprofits, + getNonprofitById, + createNonprofit, + updateNonprofit, + deleteNonprofit, + getNonprofitsWithFilters, +} from '../../../src/core'; +import { prisma } from '../../../src/config/database'; + describe('Nonprofit Service', () => { beforeEach(() => { jest.clearAllMocks(); @@ -198,7 +204,7 @@ describe('Nonprofit Service', () => { expect(result.status).toBe('ACTIVE'); expect(prisma.nonprofits.update).toHaveBeenCalledWith({ where: { nonprofit_id: nonprofitId }, - data: updateData, + data: expect.objectContaining(updateData), include: { nonprofit_chapter_project: { select: { @@ -314,9 +320,15 @@ describe('Nonprofit Service', () => { expect(result).toHaveLength(2); expect(result[0].status).toBe('ACTIVE'); expect(result[1].status).toBe('INACTIVE'); + expect(prisma.nonprofits.findMany).toHaveBeenCalledTimes(1); }); it('should filter nonprofits by chapter IDs', async () => { + const chapterId = 'chapter-1'; + ( + prisma.nonprofit_chapter_project.findMany as jest.Mock + ).mockResolvedValue([{ nonprofit_id: '1' }]); + const mockNonprofits = [ { nonprofit_id: '1', @@ -332,34 +344,44 @@ describe('Nonprofit Service', () => { start_date: baseDate, end_date: null, project_status: 'ACTIVE', - chapter_id: 'chapter-1', + chapterId, }, ], }, ]; + // service THEN queries nonprofits by nonprofit_id IN (...) (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( mockNonprofits ); - await getNonprofitsWithFilters({ chapterIds: ['chapter-1'] }); + const result = await getNonprofitsWithFilters({ + chapterIds: [chapterId], + }); + + expect(result).toHaveLength(1); + expect(result[0].nonprofit_id).toBe('1'); + + expect(prisma.nonprofit_chapter_project.findMany).toHaveBeenCalledWith({ + where: { chapter_id: { in: [chapterId] } }, + select: { nonprofit_id: true }, + }); expect(prisma.nonprofits.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { - nonprofit_chapter_project: { - some: { - chapter_id: { - in: ['chapter-1'], - }, - }, - }, - }, + where: { nonprofit_id: { in: ['1'] } }, + include: expect.any(Object), + orderBy: { created_at: 'desc' }, }) ); }); it('should filter nonprofits by ACTIVE status', async () => { + // service FIRST queries the join table using ACTIVE project criteria + ( + prisma.nonprofit_chapter_project.findMany as jest.Mock + ).mockResolvedValue([{ nonprofit_id: '1' }]); + const mockNonprofits = [ { nonprofit_id: '1', @@ -408,6 +430,16 @@ describe('Nonprofit Service', () => { expect(result).toHaveLength(1); expect(result[0].status).toBe('ACTIVE'); expect(result[0].name).toBe('Active Nonprofit'); + + expect(prisma.nonprofits.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { nonprofit_id: { in: ['1'] } }, + }) + ); + + expect(prisma.nonprofit_chapter_project.findMany).toHaveBeenCalledTimes( + 1 + ); }); it('should sort nonprofits A to Z by name', async () => { @@ -678,7 +710,6 @@ describe('Nonprofit Service', () => { sort: ['STATUS', 'MOST_RECENT'], }); - // Both active nonprofits should come first, sorted by most recent expect(result[0].name).toBe('Recent Active'); expect(result[0].status).toBe('ACTIVE'); expect(result[1].name).toBe('Old Active'); From dd59d924d5f7745689d80b4230dffd3bf86a83eb Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 27 Dec 2025 12:47:04 -0800 Subject: [PATCH 15/22] fix: ensure proper formatting in configuration files and add missing newlines --- .eslintrc.json | 73 ++++++++------- .github/workflows/ci.yml | 2 +- .github/workflows/develop_operations-api.yml | 98 ++++++++++---------- tsconfig.json | 2 +- 4 files changed, 89 insertions(+), 86 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 9c936c5..634d640 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,37 +1,40 @@ { - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2020, - "sourceType": "module" - }, - "plugins": ["@typescript-eslint"], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], - "ignorePatterns": [ - "node_modules/**", - "*.config.mts", - "prisma/**", - "legacy**", - "generated/**" - ], - "rules": { - "@typescript-eslint/no-unused-expressions": [ - "error", - { "allowShortCircuit": true, "allowTernary": true, "allowTaggedTemplates": true } - ] - }, - "overrides": [ - { - "files": ["tests/**/*.ts", "tests/**/*.js"], - "rules": { - "@typescript-eslint/no-require-imports": "off", - "@typescript-eslint/no-unused-expressions": "off" - } - } + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "ignorePatterns": [ + "node_modules/**", + "*.config.mts", + "prisma/**", + "legacy**", + "generated/**" + ], + "rules": { + "@typescript-eslint/no-unused-expressions": [ + "error", + { + "allowShortCircuit": true, + "allowTernary": true, + "allowTaggedTemplates": true + } ] - } - \ No newline at end of file + }, + "overrides": [ + { + "files": ["tests/**/*.ts", "tests/**/*.js"], + "rules": { + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-unused-expressions": "off" + } + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a3f396..50fb99f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,4 +60,4 @@ jobs: - name: Stop test database if: always() - run: docker compose down -v \ No newline at end of file + run: docker compose down -v diff --git a/.github/workflows/develop_operations-api.yml b/.github/workflows/develop_operations-api.yml index 1b0a03a..1ff71b9 100644 --- a/.github/workflows/develop_operations-api.yml +++ b/.github/workflows/develop_operations-api.yml @@ -1,49 +1,49 @@ -# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy -# More GitHub Actions for Azure: https://github.com/Azure/actions - -name: Build and deploy Node.js app to Azure Web App - operations-api - -on: - push: - branches: - - develop - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read #This is required for actions/checkout - - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js version - uses: actions/setup-node@v3 - with: - node-version: '24.x' - - - name: Upload artifact for deployment job - uses: actions/upload-artifact@v4 - with: - name: node-app - path: . - - deploy: - runs-on: ubuntu-latest - needs: build - - steps: - - name: Download artifact from build job - uses: actions/download-artifact@v4 - with: - name: node-app - - - name: 'Deploy to Azure Web App' - id: deploy-to-webapp - uses: azure/webapps-deploy@v3 - with: - app-name: 'operations-api' - slot-name: 'Production' - package: . - publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_FD3F1E6E02E34288BF14598390887468 }} \ No newline at end of file +# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy +# More GitHub Actions for Azure: https://github.com/Azure/actions + +name: Build and deploy Node.js app to Azure Web App - operations-api + +on: + push: + branches: + - develop + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read #This is required for actions/checkout + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js version + uses: actions/setup-node@v3 + with: + node-version: '24.x' + + - name: Upload artifact for deployment job + uses: actions/upload-artifact@v4 + with: + name: node-app + path: . + + deploy: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: node-app + + - name: 'Deploy to Azure Web App' + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'operations-api' + slot-name: 'Production' + package: . + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_FD3F1E6E02E34288BF14598390887468 }} diff --git a/tsconfig.json b/tsconfig.json index c279259..8ec16e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "skipLibCheck": true, "moduleResolution": "node16", "forceConsistentCasingInFileNames": true, - "isolatedModules": true, + "isolatedModules": true }, "include": ["src/**/*.ts"], "exclude": [ From 4d684b84acb5f0537c435e80793843f69bbb8d33 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 27 Dec 2025 12:47:10 -0800 Subject: [PATCH 16/22] fix: improve type safety in nonprofit resolvers by refining chapter filtering --- src/api/graphql/resolvers/nonprofits.resolvers.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/api/graphql/resolvers/nonprofits.resolvers.ts b/src/api/graphql/resolvers/nonprofits.resolvers.ts index 3c78b16..f7ce315 100644 --- a/src/api/graphql/resolvers/nonprofits.resolvers.ts +++ b/src/api/graphql/resolvers/nonprofits.resolvers.ts @@ -84,10 +84,17 @@ export const nonprofitResolvers = { }); return rows - .filter((r) => r.chapters !== null) + .filter( + ( + r + ): r is { + project_status: status_type; + chapters: { chapter_id: string; name: string }; + } => r.chapters !== null + ) .map((r) => ({ - chapter_id: r.chapters!.chapter_id, - chapter_name: r.chapters!.name, + chapter_id: r.chapters.chapter_id, + chapter_name: r.chapters.name, project_status: r.project_status === status_type.ACTIVE ? 'ACTIVE' : 'INACTIVE', })); From 26b7cc8b7e4d2f1765480a6ed5434c1ea17596cf Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 17 Jan 2026 18:17:33 -0800 Subject: [PATCH 17/22] fix: refine nonprofit query logic to handle chapter filtering and ensure unique chapter results --- .../graphql/resolvers/nonprofits.resolvers.ts | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/api/graphql/resolvers/nonprofits.resolvers.ts b/src/api/graphql/resolvers/nonprofits.resolvers.ts index f7ce315..e0db50c 100644 --- a/src/api/graphql/resolvers/nonprofits.resolvers.ts +++ b/src/api/graphql/resolvers/nonprofits.resolvers.ts @@ -21,7 +21,6 @@ type CreateNonprofitInput = z.infer; type UpdateNonprofitInput = z.infer; interface NonprofitsQueryArgs { - chapterIds?: string[]; chapterNames?: string[]; statuses?: StatusType[]; sort?: NonprofitSortOption[]; @@ -31,23 +30,27 @@ export const nonprofitResolvers = { Query: { nonprofits: async ( _parent: unknown, - { chapterIds, chapterNames, statuses, sort }: NonprofitsQueryArgs + { chapterNames, statuses, sort }: NonprofitsQueryArgs ) => { - let resolvedChapterIds = chapterIds; + let resolvedChapterIds: string[] | undefined; - if ( - (!resolvedChapterIds || resolvedChapterIds.length === 0) && - chapterNames?.length - ) { + if (chapterNames?.length) { resolvedChapterIds = await getChapterIdsByNames(chapterNames); if (!resolvedChapterIds.length) return []; } - return getNonprofitsWithFilters({ - chapterIds: resolvedChapterIds, + const nonprofits = await getNonprofitsWithFilters({ statuses, sort, - }); + ...(resolvedChapterIds?.length + ? { chapterIds: resolvedChapterIds } + : {}), + } as any); + + return nonprofits.map((np) => ({ + ...np, + chapters: [], + })); }, nonprofit: async (_parent: unknown, { id }: { id: string }) => { @@ -63,7 +66,11 @@ export const nonprofitResolvers = { { code: 'NOT_FOUND' } ); } - return nonprofit; + + return { + ...nonprofit, + chapters: [], + }; }, }, @@ -73,6 +80,7 @@ export const nonprofitResolvers = { where: { nonprofit_id: parent.nonprofit_id }, select: { project_status: true, + chapter_id: true, chapters: { select: { chapter_id: true, @@ -83,18 +91,29 @@ export const nonprofitResolvers = { orderBy: { created_at: 'desc' }, }); + if (!rows.length) return []; + + const seen = new Set(); + return rows .filter( ( r ): r is { project_status: status_type; - chapters: { chapter_id: string; name: string }; + chapter_id: string; + chapters: { chapter_id: string; name: string } | null; } => r.chapters !== null ) + .filter((r) => { + const id = r.chapters!.chapter_id; + if (seen.has(id)) return false; + seen.add(id); + return true; + }) .map((r) => ({ - chapter_id: r.chapters.chapter_id, - chapter_name: r.chapters.name, + chapter_id: r.chapters!.chapter_id, + chapter_name: r.chapters!.name, project_status: r.project_status === status_type.ACTIVE ? 'ACTIVE' : 'INACTIVE', })); From cf0f11c1e2a6e3f557d3c09692860bf170662180 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 17 Jan 2026 18:17:37 -0800 Subject: [PATCH 18/22] fix: remove unused chapterIds parameter from nonprofit query schema --- src/api/graphql/schemas/nonprofits.schema.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/api/graphql/schemas/nonprofits.schema.ts b/src/api/graphql/schemas/nonprofits.schema.ts index 8045d1e..4dd47ef 100644 --- a/src/api/graphql/schemas/nonprofits.schema.ts +++ b/src/api/graphql/schemas/nonprofits.schema.ts @@ -22,14 +22,11 @@ export const nonprofitSchemaString = ` created_at: String! updated_at: String! status: StatusType! - - # chapter + project status info (pulled internally from nonprofit_chapter_project) chapters: [NonprofitChapterInfo!]! } type Query { nonprofits( - chapterIds: [ID!] chapterNames: [String!] statuses: [StatusType!] sort: [NonprofitSortOption!] From c0d3a4a06331d3baf864ad95320fa03c6477f031 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 17 Jan 2026 18:17:43 -0800 Subject: [PATCH 19/22] fix: remove chapterIds from GetNonprofitsFilters and improve project status logic --- src/core/services/nonprofits.service.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index 4a4a5a2..6913e6e 100644 --- a/src/core/services/nonprofits.service.ts +++ b/src/core/services/nonprofits.service.ts @@ -20,7 +20,6 @@ export type NonprofitSortOption = | 'STATUS'; export interface GetNonprofitsFilters { - chapterIds?: string[]; statuses?: StatusType[]; sort?: NonprofitSortOption[]; } @@ -43,16 +42,20 @@ export interface EnrichedNonprofit { */ function deriveNonprofitStatus( projects: { - project_status: string; + project_status: status_type | string; end_date: Date | null; }[] ): StatusType { const now = new Date(); const hasOngoingProject = projects.some((project) => { - return ( - project.project_status === 'ACTIVE' && - (project.end_date === null || new Date(project.end_date) > now) - ); + const isActive = + project.project_status === status_type.ACTIVE || + project.project_status === 'ACTIVE'; + + const notEnded = + project.end_date === null || new Date(project.end_date) > now; + + return isActive && notEnded; }); return hasOngoingProject ? 'ACTIVE' : 'INACTIVE'; } @@ -128,7 +131,7 @@ export async function getNonprofitsWithFilters( filters: GetNonprofitsFilters = {} ): Promise { try { - const { chapterIds, statuses, sort } = filters; + const { statuses, sort } = filters; logger.info('Fetching nonprofits with filters', { filters }); @@ -139,19 +142,13 @@ export async function getNonprofitsWithFilters( OR: [{ end_date: null }, { end_date: { gt: now } }], }; - const hasJoinFilters = - (chapterIds && chapterIds.length > 0) || - (statuses && statuses.length > 0); + const hasJoinFilters = statuses && statuses.length > 0; let nonprofitIdFilter: Prisma.nonprofitsWhereInput | undefined; if (hasJoinFilters) { const ncpWhere: Prisma.nonprofit_chapter_projectWhereInput = {}; - if (chapterIds?.length) { - ncpWhere.chapter_id = { in: chapterIds }; - } - if (statuses?.length) { const wantsActive = statuses.includes('ACTIVE'); const wantsInactive = statuses.includes('INACTIVE'); From b6ca53b411c5842939d0395e8b6f67a0b941aab6 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sat, 17 Jan 2026 18:17:52 -0800 Subject: [PATCH 20/22] fix: optimize nonprofit query logic for improved chapter filtering accuracy --- .../unit/services/nonprofits.service.test.ts | 61 ++----------------- 1 file changed, 4 insertions(+), 57 deletions(-) diff --git a/tests/unit/services/nonprofits.service.test.ts b/tests/unit/services/nonprofits.service.test.ts index 84a76e4..4f69b0b 100644 --- a/tests/unit/services/nonprofits.service.test.ts +++ b/tests/unit/services/nonprofits.service.test.ts @@ -64,7 +64,7 @@ describe('Nonprofit Service', () => { it('should return a single nonprofit when a valid ID is provided', async () => { const nonprofitId = '1'; const baseDate = new Date(); - const futureDate = new Date('2025-12-31'); + const futureDate = new Date('2026-12-31'); const mockNonprofit = { nonprofit_id: '1', name: 'Test Nonprofit 1', @@ -83,6 +83,7 @@ describe('Nonprofit Service', () => { }, ], }; + (prisma.nonprofits.findUnique as jest.Mock).mockResolvedValue( mockNonprofit ); @@ -172,7 +173,7 @@ describe('Nonprofit Service', () => { const nonprofitId = '1'; const updateData = { name: 'Updated Nonprofit Name' }; const baseDate = new Date(); - const futureDate = new Date('2025-12-31'); + const futureDate = new Date('2026-12-31'); const updatedNonprofit = { nonprofit_id: '1', @@ -268,7 +269,7 @@ describe('Nonprofit Service', () => { describe('getNonprofitsWithFilters', () => { const baseDate = new Date('2024-01-01'); - const futureDate = new Date('2025-12-31'); + const futureDate = new Date('2026-12-31'); const pastDate = new Date('2023-01-01'); it('should return all nonprofits with derived status when no filters are provided', async () => { @@ -323,61 +324,7 @@ describe('Nonprofit Service', () => { expect(prisma.nonprofits.findMany).toHaveBeenCalledTimes(1); }); - it('should filter nonprofits by chapter IDs', async () => { - const chapterId = 'chapter-1'; - ( - prisma.nonprofit_chapter_project.findMany as jest.Mock - ).mockResolvedValue([{ nonprofit_id: '1' }]); - - const mockNonprofits = [ - { - nonprofit_id: '1', - name: 'Nonprofit 1', - mission: 'Test mission', - website: null, - location_id: null, - contact_id: 'contact-1', - created_at: baseDate, - updated_at: baseDate, - nonprofit_chapter_project: [ - { - start_date: baseDate, - end_date: null, - project_status: 'ACTIVE', - chapterId, - }, - ], - }, - ]; - - // service THEN queries nonprofits by nonprofit_id IN (...) - (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( - mockNonprofits - ); - - const result = await getNonprofitsWithFilters({ - chapterIds: [chapterId], - }); - - expect(result).toHaveLength(1); - expect(result[0].nonprofit_id).toBe('1'); - - expect(prisma.nonprofit_chapter_project.findMany).toHaveBeenCalledWith({ - where: { chapter_id: { in: [chapterId] } }, - select: { nonprofit_id: true }, - }); - - expect(prisma.nonprofits.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { nonprofit_id: { in: ['1'] } }, - include: expect.any(Object), - orderBy: { created_at: 'desc' }, - }) - ); - }); - it('should filter nonprofits by ACTIVE status', async () => { - // service FIRST queries the join table using ACTIVE project criteria ( prisma.nonprofit_chapter_project.findMany as jest.Mock ).mockResolvedValue([{ nonprofit_id: '1' }]); From b01f47a6817dcfe1da3d2004f6e36f03822888a0 Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sun, 18 Jan 2026 18:47:55 -0800 Subject: [PATCH 21/22] fix: enhance nonprofit query logic for better chapter filtering and uniqueness --- .../graphql/resolvers/nonprofits.resolvers.ts | 70 ++++++++++++------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/src/api/graphql/resolvers/nonprofits.resolvers.ts b/src/api/graphql/resolvers/nonprofits.resolvers.ts index e0db50c..e7dbde9 100644 --- a/src/api/graphql/resolvers/nonprofits.resolvers.ts +++ b/src/api/graphql/resolvers/nonprofits.resolvers.ts @@ -26,6 +26,22 @@ interface NonprofitsQueryArgs { sort?: NonprofitSortOption[]; } +interface GetNonprofitsWithFiltersArgs { + statuses?: StatusType[]; + sort?: NonprofitSortOption[]; + chapterIds?: string[]; +} + +interface NonprofitParent { + nonprofit_id: string; +} + +interface ChapterRow { + project_status: status_type; + chapter_id: string | null; + chapters: { chapter_id: string; name: string } | null; +} + export const nonprofitResolvers = { Query: { nonprofits: async ( @@ -39,13 +55,15 @@ export const nonprofitResolvers = { if (!resolvedChapterIds.length) return []; } - const nonprofits = await getNonprofitsWithFilters({ + const serviceArgs: GetNonprofitsWithFiltersArgs = { statuses, sort, ...(resolvedChapterIds?.length ? { chapterIds: resolvedChapterIds } : {}), - } as any); + }; + + const nonprofits = await getNonprofitsWithFilters(serviceArgs); return nonprofits.map((np) => ({ ...np, @@ -75,8 +93,8 @@ export const nonprofitResolvers = { }, Nonprofit: { - chapters: async (parent: { nonprofit_id: string }) => { - const rows = await prisma.nonprofit_chapter_project.findMany({ + chapters: async (parent: NonprofitParent) => { + const rows = (await prisma.nonprofit_chapter_project.findMany({ where: { nonprofit_id: parent.nonprofit_id }, select: { project_status: true, @@ -89,34 +107,32 @@ export const nonprofitResolvers = { }, }, orderBy: { created_at: 'desc' }, - }); + })) as ChapterRow[]; if (!rows.length) return []; + const rowsWithChapter = rows.filter( + ( + r + ): r is ChapterRow & { + chapters: { chapter_id: string; name: string }; + } => r.chapters !== null + ); + const seen = new Set(); + const deduped = rowsWithChapter.filter((r) => { + const id = r.chapters.chapter_id; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); - return rows - .filter( - ( - r - ): r is { - project_status: status_type; - chapter_id: string; - chapters: { chapter_id: string; name: string } | null; - } => r.chapters !== null - ) - .filter((r) => { - const id = r.chapters!.chapter_id; - if (seen.has(id)) return false; - seen.add(id); - return true; - }) - .map((r) => ({ - chapter_id: r.chapters!.chapter_id, - chapter_name: r.chapters!.name, - project_status: - r.project_status === status_type.ACTIVE ? 'ACTIVE' : 'INACTIVE', - })); + return deduped.map((r) => ({ + chapter_id: r.chapters.chapter_id, + chapter_name: r.chapters.name, + project_status: + r.project_status === status_type.ACTIVE ? 'ACTIVE' : 'INACTIVE', + })); }, }, From 7936aad403ee0e77effa1326b4fde89b5ffc558b Mon Sep 17 00:00:00 2001 From: amandayu255 Date: Sun, 18 Jan 2026 20:38:28 -0800 Subject: [PATCH 22/22] fix: enhance nonprofit query logic for improved chapter filtering and uniqueness --- .../graphql/nonprofits-sorting.test.ts | 113 +++++++++++++++--- 1 file changed, 95 insertions(+), 18 deletions(-) diff --git a/tests/integration/graphql/nonprofits-sorting.test.ts b/tests/integration/graphql/nonprofits-sorting.test.ts index 8b98f78..b6257fa 100644 --- a/tests/integration/graphql/nonprofits-sorting.test.ts +++ b/tests/integration/graphql/nonprofits-sorting.test.ts @@ -15,6 +15,7 @@ let server: Server; let app: express.Application; let contactId: string; let chapterId: string; +let chapterName: string; let projectId: string; let nonprofit1Id: string; let nonprofit2Id: string; @@ -24,7 +25,14 @@ beforeAll(async () => { app = createGraphqlApp(); server = app.listen(env.GRAPHQL_PORT); - const contactMutation = `mutation CreateContact($input: CreateContactInput!) { createContact(input: $input) { contact_id } }`; + const contactMutation = ` + mutation CreateContact($input: CreateContactInput!) { + createContact(input: $input) { + contact_id + } + } + `; + const contactResponse = await request(server) .post('/') .send({ @@ -37,9 +45,17 @@ beforeAll(async () => { }, }, }); - contactId = contactResponse.body.data.createContact.contact_id; - const createNonprofitMutation = `mutation CreateNonprofit($input: CreateNonprofitInput!) { createNonprofit(input: $input) { nonprofit_id } }`; + contactId = contactResponse.body.data.createContact.contact_id as string; + + const createNonprofitMutation = ` + mutation CreateNonprofit($input: CreateNonprofitInput!) { + createNonprofit(input: $input) { + nonprofit_id + } + } + `; + const np1 = await request(server) .post('/') .send({ @@ -52,7 +68,8 @@ beforeAll(async () => { }, }, }); - nonprofit1Id = np1.body.data.createNonprofit.nonprofit_id; + nonprofit1Id = np1.body.data.createNonprofit.nonprofit_id as string; + const np2 = await request(server) .post('/') .send({ @@ -65,7 +82,8 @@ beforeAll(async () => { }, }, }); - nonprofit2Id = np2.body.data.createNonprofit.nonprofit_id; + nonprofit2Id = np2.body.data.createNonprofit.nonprofit_id as string; + const np3 = await request(server) .post('/') .send({ @@ -78,30 +96,43 @@ beforeAll(async () => { }, }, }); - nonprofit3Id = np3.body.data.createNonprofit.nonprofit_id; + nonprofit3Id = np3.body.data.createNonprofit.nonprofit_id as string; }); -// Recreate chapter, project, and nonprofit_chapter_project records before each test -// This is necessary because the global beforeEach in tests/setup.ts deletes them beforeEach(async () => { - // Recreate chapter - const chapterMutation = `mutation CreateChapter($input: CreateChapterInput!) { createChapter(input: $input) { chapter_id } }`; + chapterName = 'Test Chapter for Sorting'; + + const chapterMutation = ` + mutation CreateChapter($input: CreateChapterInput!) { + createChapter(input: $input) { + chapter_id + } + } + `; + const chapterResponse = await request(server) .post('/') .send({ query: chapterMutation, variables: { input: { - name: 'Test Chapter for Sorting', + name: chapterName, founded_date: '2020-01-01T00:00:00.000Z', status_type: 'ACTIVE', }, }, }); - chapterId = chapterResponse.body.data.createChapter.chapter_id; - // Recreate project - const projectMutation = `mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { project_id } }`; + chapterId = chapterResponse.body.data.createChapter.chapter_id as string; + + const projectMutation = ` + mutation CreateProject($input: CreateProjectInput!) { + createProject(input: $input) { + project_id + } + } + `; + const projectResponse = await request(server) .post('/') .send({ @@ -114,9 +145,9 @@ beforeEach(async () => { }, }, }); - projectId = projectResponse.body.data.createProject.project_id; - // Recreate nonprofit_chapter_project records + projectId = projectResponse.body.data.createProject.project_id as string; + await prisma.nonprofit_chapter_project.create({ data: { nonprofit_id: nonprofit1Id, @@ -129,6 +160,7 @@ beforeEach(async () => { project_status: 'ACTIVE', }, }); + await prisma.nonprofit_chapter_project.create({ data: { nonprofit_id: nonprofit2Id, @@ -141,6 +173,7 @@ beforeEach(async () => { project_status: 'INACTIVE', }, }); + await prisma.nonprofit_chapter_project.create({ data: { nonprofit_id: nonprofit3Id, @@ -159,6 +192,7 @@ afterAll(async () => { const nonprofitIds = [nonprofit1Id, nonprofit2Id, nonprofit3Id].filter( (id) => id !== undefined ); + if (nonprofitIds.length > 0) { await prisma.nonprofit_chapter_project.deleteMany({ where: { nonprofit_id: { in: nonprofitIds } }, @@ -167,6 +201,7 @@ afterAll(async () => { where: { nonprofit_id: { in: nonprofitIds } }, }); } + if (projectId) { await prisma.projects.deleteMany({ where: { project_id: projectId } }); } @@ -176,23 +211,39 @@ afterAll(async () => { if (contactId) { await prisma.contacts.deleteMany({ where: { contact_id: contactId } }); } + server.close(); }); describe('GraphQL API - Nonprofits Sorting and Filtering', () => { - const nonprofitsQuery = `query Nonprofits($chapterIds: [ID!], $statuses: [StatusType!], $sort: [NonprofitSortOption!]) { nonprofits(chapterIds: $chapterIds, statuses: $statuses, sort: $sort) { nonprofit_id name status } }`; + const nonprofitsQuery = ` + query Nonprofits( + $chapterNames: [String!] + $statuses: [StatusType!] + $sort: [NonprofitSortOption!] + ) { + nonprofits(chapterNames: $chapterNames, statuses: $statuses, sort: $sort) { + nonprofit_id + name + status + } + } + `; it('should sort nonprofits A to Z by name', async () => { const response = await request(server) .post('/') .send({ query: nonprofitsQuery, variables: { sort: ['A_TO_Z'] } }); + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( (np: NonprofitResponse) => ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( np.name ) ); + expect(testNPs[0].name).toBe('Alpha Nonprofit'); expect(testNPs[1].name).toBe('Beta Nonprofit'); expect(testNPs[2].name).toBe('Zebra Nonprofit'); @@ -202,12 +253,16 @@ describe('GraphQL API - Nonprofits Sorting and Filtering', () => { const response = await request(server) .post('/') .send({ query: nonprofitsQuery, variables: { sort: ['Z_TO_A'] } }); + + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( (np: NonprofitResponse) => ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( np.name ) ); + expect(testNPs[0].name).toBe('Zebra Nonprofit'); expect(testNPs[2].name).toBe('Alpha Nonprofit'); }); @@ -216,12 +271,16 @@ describe('GraphQL API - Nonprofits Sorting and Filtering', () => { const response = await request(server) .post('/') .send({ query: nonprofitsQuery, variables: { sort: ['MOST_RECENT'] } }); + + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( (np: NonprofitResponse) => ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( np.name ) ); + expect(testNPs[0].name).toBe('Zebra Nonprofit'); }); @@ -229,12 +288,16 @@ describe('GraphQL API - Nonprofits Sorting and Filtering', () => { const response = await request(server) .post('/') .send({ query: nonprofitsQuery, variables: { sort: ['STATUS'] } }); + + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( (np: NonprofitResponse) => ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( np.name ) ); + expect( testNPs.filter((np: NonprofitResponse) => np.status === 'ACTIVE').length ).toBe(2); @@ -244,12 +307,16 @@ describe('GraphQL API - Nonprofits Sorting and Filtering', () => { const response = await request(server) .post('/') .send({ query: nonprofitsQuery, variables: { statuses: ['ACTIVE'] } }); + + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( (np: NonprofitResponse) => ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( np.name ) ); + expect(testNPs.length).toBe(2); expect( testNPs.every((np: NonprofitResponse) => np.status === 'ACTIVE') @@ -259,14 +326,20 @@ describe('GraphQL API - Nonprofits Sorting and Filtering', () => { it('should filter by chapter', async () => { const response = await request(server) .post('/') - .send({ query: nonprofitsQuery, variables: { chapterIds: [chapterId] } }); + .send({ + query: nonprofitsQuery, + variables: { chapterNames: [chapterName] }, + }); + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( (np: NonprofitResponse) => ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( np.name ) ); + expect(testNPs.length).toBe(3); }); @@ -277,12 +350,16 @@ describe('GraphQL API - Nonprofits Sorting and Filtering', () => { query: nonprofitsQuery, variables: { sort: ['STATUS', 'MOST_RECENT'] }, }); + + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( (np: NonprofitResponse) => ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( np.name ) ); + expect(testNPs[0].status).toBe('ACTIVE'); expect(testNPs[0].name).toBe('Zebra Nonprofit'); });