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 diff --git a/src/api/graphql/resolvers/nonprofits.resolvers.ts b/src/api/graphql/resolvers/nonprofits.resolvers.ts index f38ed8a..ca384f9 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'; @@ -13,25 +14,48 @@ import type { StatusType, NonprofitSortOption, } from '../../../core/services/nonprofits.service'; +import { prisma } from '../../../config/database'; +import { Prisma, status_type } from '@prisma/client'; -// 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[]; } +interface NonprofitChapterProjectsArgs { + nonprofitIds?: string[]; + chapterIds?: string[]; + projectStatuses?: StatusType[]; +} + 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 ( + (!resolvedChapterIds || resolvedChapterIds.length === 0) && + chapterNames?.length + ) { + resolvedChapterIds = await getChapterIdsByNames(chapterNames); + 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) { @@ -47,7 +71,37 @@ 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: { createNonprofit: ( _parent: unknown, @@ -56,6 +110,7 @@ export const nonprofitResolvers = { const validatedInput = createNonprofitSchema.parse(input); return createNonprofit(validatedInput); }, + updateNonprofit: ( _parent: unknown, { @@ -66,6 +121,7 @@ export const nonprofitResolvers = { const validatedInput = updateNonprofitSchema.parse(input); return updateNonprofit(nonprofit_id, validatedInput); }, + deleteNonprofit: (_parent: unknown, { id }: { id: string }) => { return deleteNonprofit(id); }, diff --git a/src/api/graphql/schemas/nonprofits.schema.ts b/src/api/graphql/schemas/nonprofits.schema.ts index 46e71eb..ebc4b4f 100644 --- a/src/api/graphql/schemas/nonprofits.schema.ts +++ b/src/api/graphql/schemas/nonprofits.schema.ts @@ -18,13 +18,36 @@ 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!] + chapterNames: [String!] statuses: [StatusType!] sort: [NonprofitSortOption!] ): [Nonprofit!]! + nonprofit(id: ID!): Nonprofit + + nonprofitChapterProjects( + nonprofitIds: [ID!] + chapterIds: [ID!] + projectStatuses: [StatusType!] + ): [NonprofitChapterProject!]! } input CreateNonprofitInput { diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index 0b65630..57aebaf 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,51 @@ 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 } }, }, - }; + }); + } + + // 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, + }, + }); + } + } + + let where: Prisma.nonprofitsWhereInput = {}; + if (andConditions.length === 1) { + where = andConditions[0]; + } else if (andConditions.length > 1) { + where = { AND: andConditions }; } - // Fetch nonprofits with related projects const nonprofits = await prisma.nonprofits.findMany({ where, include: { @@ -159,10 +190,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 +211,21 @@ export async function getNonprofitsWithFilters( }; }); - // Apply status filter - let filteredNonprofits = enrichedNonprofits; + let result = enrichedNonprofits; if (statuses && statuses.length > 0) { - filteredNonprofits = enrichedNonprofits.filter((np) => - statuses.includes(np.status) - ); + result = enrichedNonprofits.filter((np) => statuses.includes(np.status)); } - // Apply sorting if (sort && sort.length > 0) { const comparator = createNonprofitComparator(sort); - filteredNonprofits.sort(comparator); + result = [...result].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', @@ -519,3 +545,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); +}