Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
It helps you see when data is created in the local database!
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 60 additions & 4 deletions src/api/graphql/resolvers/nonprofits.resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,56 @@ import {
getNonprofitsWithFilters,
updateNonprofit,
updateNonprofitSchema,
getChapterIdsByNames,
} from '../../../core';
import { GraphQLError } from 'graphql';
import { z } from 'zod';
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<typeof createNonprofitSchema>;
type UpdateNonprofitInput = z.infer<typeof updateNonprofitSchema>;

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) {
Expand All @@ -47,7 +71,37 @@ export const nonprofitResolvers = {
}
return nonprofit;
},

nonprofitChapterProjects: async (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that you are creating a new API for nonprofit_chapter_projects. For our design of the api's, we've already created all APIs which are individual table entities and not join tables like nonprofit_chapter_projects. We don't want to expose them and have them be able to be called publicly. And so ultimately the goal is to be able to just call something like this from Postman:

query {
nonprofits(
chapterNames: [...]
statuses: [...]
) {
id
name
chapters {
id
status
}
}
}

And be able to retrieve proper nonprofits based on the chapter and/or status name. And internally we would use the nonprofit_chapter_project table to gather the right data we need. Hopefully that makes sense and let me know if you have questions!

_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,
Expand All @@ -56,6 +110,7 @@ export const nonprofitResolvers = {
const validatedInput = createNonprofitSchema.parse(input);
return createNonprofit(validatedInput);
},

updateNonprofit: (
_parent: unknown,
{
Expand All @@ -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);
},
Expand Down
23 changes: 23 additions & 0 deletions src/api/graphql/schemas/nonprofits.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
80 changes: 59 additions & 21 deletions src/core/services/nonprofits.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createNonprofitSchema>;
type UpdateNonprofitInput = z.infer<typeof updateNonprofitSchema>;
Expand Down Expand Up @@ -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: {
Expand All @@ -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);
Expand All @@ -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',
Expand Down Expand Up @@ -519,3 +545,15 @@ export async function deleteNonprofit(id: string): Promise<EnrichedNonprofit> {
throw new DatabaseError('Failed to delete nonprofit');
}
}

export async function getChapterIdsByNames(names: string[]): Promise<string[]> {
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);
}