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/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..f7ce315 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,42 @@ import type { StatusType, NonprofitSortOption, } from '../../../core/services/nonprofits.service'; +import { prisma } from '../../../config/database'; +import { 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[]; } 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.length) return []; + } + + return getNonprofitsWithFilters({ + chapterIds: resolvedChapterIds, + statuses, + sort, + }); }, + nonprofit: async (_parent: unknown, { id }: { id: string }) => { const nonprofit = await getNonprofitById(id); if (!nonprofit) { @@ -48,6 +66,41 @@ export const nonprofitResolvers = { return nonprofit; }, }, + + Nonprofit: { + chapters: async (parent: { nonprofit_id: string }) => { + const rows = await prisma.nonprofit_chapter_project.findMany({ + where: { nonprofit_id: parent.nonprofit_id }, + select: { + project_status: true, + chapters: { + select: { + chapter_id: true, + name: true, + }, + }, + }, + orderBy: { created_at: 'desc' }, + }); + + return rows + .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, + project_status: + r.project_status === status_type.ACTIVE ? 'ACTIVE' : 'INACTIVE', + })); + }, + }, + Mutation: { createNonprofit: ( _parent: unknown, @@ -56,6 +109,7 @@ export const nonprofitResolvers = { const validatedInput = createNonprofitSchema.parse(input); return createNonprofit(validatedInput); }, + updateNonprofit: ( _parent: unknown, { @@ -66,6 +120,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..8045d1e 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,14 +22,19 @@ 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!] ): [Nonprofit!]! + nonprofit(id: ID!): Nonprofit } diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index 0b65630..4a4a5a2 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'; @@ -7,7 +6,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,22 +132,56 @@ export async function getNonprofitsWithFilters( logger.info('Fetching nonprofits with filters', { filters }); - // Build Prisma where clause - const where: Prisma.nonprofitsWhereInput = {}; + const now = new Date(); - if (chapterIds && chapterIds.length > 0) { - where.nonprofit_chapter_project = { - some: { - chapter_id: { - in: chapterIds, - }, - }, + const activeProjectWhere: Prisma.nonprofit_chapter_projectWhereInput = { + project_status: status_type.ACTIVE, + OR: [{ end_date: null }, { end_date: { gt: now } }], + }; + + const hasJoinFilters = + (chapterIds && chapterIds.length > 0) || + (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'); + + // 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: ncpWhere, + select: { nonprofit_id: true }, + }); + + if (joinRows.length === 0) return []; + + const nonprofitIds = [...new Set(joinRows.map((r) => r.nonprofit_id))]; + + nonprofitIdFilter = { + nonprofit_id: { in: nonprofitIds }, }; } - // Fetch nonprofits with related projects const nonprofits = await prisma.nonprofits.findMany({ - where, + where: nonprofitIdFilter, include: { nonprofit_chapter_project: { select: { @@ -159,10 +192,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 +213,21 @@ export async function getNonprofitsWithFilters( }; }); - // Apply status filter - let filteredNonprofits = enrichedNonprofits; - if (statuses && statuses.length > 0) { - filteredNonprofits = enrichedNonprofits.filter((np) => - statuses.includes(np.status) - ); + let result = enrichedNonprofits; + if (statuses?.length) { + result = result.filter((np) => statuses.includes(np.status)); } - // Apply sorting - if (sort && sort.length > 0) { + if (sort?.length) { 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', @@ -302,8 +330,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) }; @@ -519,3 +547,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); +} 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'); 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": [