From 419a896b65397c3206f44d11da64465f6d18c3da Mon Sep 17 00:00:00 2001 From: Rob Donigian Date: Fri, 18 Jul 2025 15:57:46 -0400 Subject: [PATCH 1/4] Add projects computed links to FieldRegion and FieldZone --- dbschema/field-region.gel | 2 ++ dbschema/field-zone.gel | 1 + dbschema/migrations/00020-m1cjlgl.edgeql | 10 ++++++++++ 3 files changed, 13 insertions(+) create mode 100644 dbschema/migrations/00020-m1cjlgl.edgeql diff --git a/dbschema/field-region.gel b/dbschema/field-region.gel index 9eb1be66c2..13882d67e6 100644 --- a/dbschema/field-region.gel +++ b/dbschema/field-region.gel @@ -6,5 +6,7 @@ module default { required fieldZone: FieldZone; required director: User; + + multi projects := . Date: Mon, 21 Jul 2025 08:34:23 -0400 Subject: [PATCH 2/4] Fix circular dependencies, prevents module initialization errors when FieldRegionModule imports ProjectModule --- src/components/location/location.module.ts | 2 +- src/components/organization/organization.module.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/location/location.module.ts b/src/components/location/location.module.ts index d4903efffe..44e66ba9c9 100644 --- a/src/components/location/location.module.ts +++ b/src/components/location/location.module.ts @@ -15,7 +15,7 @@ import { DefaultMarketingRegionMigration } from './migrations/default-marketing- imports: [ forwardRef(() => AuthorizationModule), forwardRef(() => FundingAccountModule), - FieldRegionModule, + forwardRef(() => FieldRegionModule), FileModule, ], providers: [ diff --git a/src/components/organization/organization.module.ts b/src/components/organization/organization.module.ts index dc3aaa6172..b1190410a5 100644 --- a/src/components/organization/organization.module.ts +++ b/src/components/organization/organization.module.ts @@ -11,7 +11,10 @@ import { OrganizationResolver } from './organization.resolver'; import { OrganizationService } from './organization.service'; @Module({ - imports: [forwardRef(() => AuthorizationModule), LocationModule], + imports: [ + forwardRef(() => AuthorizationModule), + forwardRef(() => LocationModule), + ], providers: [ OrganizationResolver, OrganizationService, From 7406ab8d31a4bd25c6804fdc6053aa9db6395244 Mon Sep 17 00:00:00 2001 From: Rob Donigian Date: Tue, 29 Jul 2025 13:24:08 -0400 Subject: [PATCH 3/4] Expose FieldRegion.projects relationship in GraphQL API --- .../field-region/dto/field-region.dto.ts | 7 +++++ .../field-region-projects.resolver.ts | 25 ++++++++++++++++ .../field-region/field-region.module.ts | 4 +++ .../field-region/field-region.service.ts | 29 +++++++++++++++++++ ...ield-region-project-connection.resolver.ts | 0 5 files changed, 65 insertions(+) create mode 100644 src/components/field-region/field-region-projects.resolver.ts create mode 100644 src/components/project/field-region-project-connection.resolver.ts diff --git a/src/components/field-region/dto/field-region.dto.ts b/src/components/field-region/dto/field-region.dto.ts index 243a27d621..0b23622fce 100644 --- a/src/components/field-region/dto/field-region.dto.ts +++ b/src/components/field-region/dto/field-region.dto.ts @@ -3,6 +3,7 @@ import { DbUnique, NameField, Resource, + type ResourceRelationsShape, type Secured, SecuredProperty, SecuredPropertyList, @@ -10,12 +11,18 @@ import { } from '~/common'; import { e } from '~/core/gel'; import { type LinkTo, RegisterResource } from '~/core/resources'; +import { IProject } from '../../project/dto'; @RegisterResource({ db: e.FieldRegion }) @ObjectType({ implements: [Resource], }) export class FieldRegion extends Resource { + static readonly Relations = () => + ({ + projects: [IProject], + } satisfies ResourceRelationsShape); + @NameField() @DbUnique() readonly name: SecuredString; diff --git a/src/components/field-region/field-region-projects.resolver.ts b/src/components/field-region/field-region-projects.resolver.ts new file mode 100644 index 0000000000..f0f7b756b6 --- /dev/null +++ b/src/components/field-region/field-region-projects.resolver.ts @@ -0,0 +1,25 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { ListArg } from '~/common'; +import { Loader, type LoaderOf } from '~/core'; +import { ProjectListInput, SecuredProjectList } from '../project/dto'; +import { ProjectLoader } from '../project/project.loader'; +import { FieldRegion } from './dto'; +import { FieldRegionService } from './field-region.service'; + +@Resolver(FieldRegion) +export class FieldRegionProjectsResolver { + constructor(private readonly fieldRegionService: FieldRegionService) {} + + @ResolveField(() => SecuredProjectList, { + description: 'The list of projects in this field region', + }) + async projects( + @Parent() fieldRegion: FieldRegion, + @ListArg(ProjectListInput) input: ProjectListInput, + @Loader(ProjectLoader) projects: LoaderOf, + ): Promise { + const list = await this.fieldRegionService.listProjects(fieldRegion, input); + projects.primeAll(list.items); + return list; + } +} diff --git a/src/components/field-region/field-region.module.ts b/src/components/field-region/field-region.module.ts index 412a739f33..149461ae11 100644 --- a/src/components/field-region/field-region.module.ts +++ b/src/components/field-region/field-region.module.ts @@ -2,7 +2,9 @@ import { forwardRef, Module } from '@nestjs/common'; import { splitDb } from '~/core'; import { AuthorizationModule } from '../authorization/authorization.module'; import { FieldZoneModule } from '../field-zone/field-zone.module'; +import { ProjectModule } from '../project/project.module'; import { UserModule } from '../user/user.module'; +import { FieldRegionProjectsResolver } from './field-region-projects.resolver'; import { FieldRegionGelRepository } from './field-region.gel.repository'; import { FieldRegionLoader } from './field-region.loader'; import { FieldRegionRepository } from './field-region.repository'; @@ -14,10 +16,12 @@ import { RestrictRegionDirectorRemovalHandler } from './handlers/restrict-region imports: [ forwardRef(() => AuthorizationModule), FieldZoneModule, + forwardRef(() => ProjectModule), forwardRef(() => UserModule), ], providers: [ FieldRegionResolver, + FieldRegionProjectsResolver, FieldRegionService, splitDb(FieldRegionRepository, FieldRegionGelRepository), FieldRegionLoader, diff --git a/src/components/field-region/field-region.service.ts b/src/components/field-region/field-region.service.ts index 939a6451e6..4b900ba423 100644 --- a/src/components/field-region/field-region.service.ts +++ b/src/components/field-region/field-region.service.ts @@ -10,6 +10,12 @@ import { import { HandleIdLookup } from '~/core'; import { IEventBus } from '~/core/events'; import { Privileges } from '../authorization'; +import { + IProject, + type ProjectListInput, + type SecuredProjectList, +} from '../project/dto'; +import { ProjectService } from '../project/project.service'; import { UserService } from '../user'; import { type CreateFieldRegion, @@ -28,6 +34,7 @@ export class FieldRegionService { private readonly events: IEventBus, private readonly users: UserService, private readonly repo: FieldRegionRepository, + private readonly projectService: ProjectService, ) {} async create(input: CreateFieldRegion): Promise { @@ -115,4 +122,26 @@ export class FieldRegionService { items: results.items.map((dto) => this.secure(dto)), }; } + + async listProjects( + fieldRegion: FieldRegion, + input: ProjectListInput, + ): Promise { + const projectListOutput = await this.projectService.list({ + ...input, + filter: { + ...input.filter, + fieldRegion: { + ...(input.filter?.fieldRegion ?? {}), + id: fieldRegion.id, + }, + }, + }); + + return { + ...projectListOutput, + canRead: true, + canCreate: this.privileges.for(IProject).can('create'), + }; + } } diff --git a/src/components/project/field-region-project-connection.resolver.ts b/src/components/project/field-region-project-connection.resolver.ts new file mode 100644 index 0000000000..e69de29bb2 From dd8615f25612bb88d16ecebff7b9adde2fdb2c2e Mon Sep 17 00:00:00 2001 From: Rob Donigian Date: Tue, 29 Jul 2025 13:24:27 -0400 Subject: [PATCH 4/4] Expose FieldZone.projects relationship in GraphQL API --- .../field-zone/dto/field-zone.dto.ts | 7 +++++ .../field-zone-projects.resolver.ts | 25 +++++++++++++++++ .../field-zone/field-zone.module.ts | 4 +++ .../field-zone/field-zone.service.ts | 28 +++++++++++++++++++ .../field-zone-project-connection.resolver.ts | 0 5 files changed, 64 insertions(+) create mode 100644 src/components/field-zone/field-zone-projects.resolver.ts create mode 100644 src/components/project/field-zone-project-connection.resolver.ts diff --git a/src/components/field-zone/dto/field-zone.dto.ts b/src/components/field-zone/dto/field-zone.dto.ts index 456e6012ae..c76ad55d7c 100644 --- a/src/components/field-zone/dto/field-zone.dto.ts +++ b/src/components/field-zone/dto/field-zone.dto.ts @@ -3,18 +3,25 @@ import { DbUnique, NameField, Resource, + type ResourceRelationsShape, type Secured, SecuredProperty, SecuredString, } from '~/common'; import { e } from '~/core/gel'; import { type LinkTo, RegisterResource } from '~/core/resources'; +import { IProject } from '../../project/dto'; @RegisterResource({ db: e.FieldZone }) @ObjectType({ implements: [Resource], }) export class FieldZone extends Resource { + static readonly Relations = () => + ({ + projects: [IProject], + } satisfies ResourceRelationsShape); + @NameField() @DbUnique() readonly name: SecuredString; diff --git a/src/components/field-zone/field-zone-projects.resolver.ts b/src/components/field-zone/field-zone-projects.resolver.ts new file mode 100644 index 0000000000..65f20eaa00 --- /dev/null +++ b/src/components/field-zone/field-zone-projects.resolver.ts @@ -0,0 +1,25 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { ListArg } from '~/common'; +import { Loader, type LoaderOf } from '~/core'; +import { ProjectListInput, SecuredProjectList } from '../project/dto'; +import { ProjectLoader } from '../project/project.loader'; +import { FieldZone } from './dto'; +import { FieldZoneService } from './field-zone.service'; + +@Resolver(FieldZone) +export class FieldZoneProjectsResolver { + constructor(private readonly fieldZoneService: FieldZoneService) {} + + @ResolveField(() => SecuredProjectList, { + description: 'The list of projects in regions within this field zone', + }) + async projects( + @Parent() fieldZone: FieldZone, + @ListArg(ProjectListInput) input: ProjectListInput, + @Loader(ProjectLoader) projects: LoaderOf, + ): Promise { + const list = await this.fieldZoneService.listProjects(fieldZone, input); + projects.primeAll(list.items); + return list; + } +} diff --git a/src/components/field-zone/field-zone.module.ts b/src/components/field-zone/field-zone.module.ts index cc000c89f8..472f87ff17 100644 --- a/src/components/field-zone/field-zone.module.ts +++ b/src/components/field-zone/field-zone.module.ts @@ -1,7 +1,9 @@ import { forwardRef, Module } from '@nestjs/common'; import { splitDb } from '~/core'; import { AuthorizationModule } from '../authorization/authorization.module'; +import { ProjectModule } from '../project/project.module'; import { UserModule } from '../user/user.module'; +import { FieldZoneProjectsResolver } from './field-zone-projects.resolver'; import { FieldZoneGelRepository } from './field-zone.gel.repository'; import { FieldZoneLoader } from './field-zone.loader'; import { FieldZoneRepository } from './field-zone.repository'; @@ -12,10 +14,12 @@ import { RestrictZoneDirectorRemovalHandler } from './handlers/restrict-zone-dir @Module({ imports: [ forwardRef(() => AuthorizationModule), + forwardRef(() => ProjectModule), forwardRef(() => UserModule), ], providers: [ FieldZoneResolver, + FieldZoneProjectsResolver, FieldZoneService, splitDb(FieldZoneRepository, FieldZoneGelRepository), FieldZoneLoader, diff --git a/src/components/field-zone/field-zone.service.ts b/src/components/field-zone/field-zone.service.ts index 3e7f7efcd9..15a6f03d06 100644 --- a/src/components/field-zone/field-zone.service.ts +++ b/src/components/field-zone/field-zone.service.ts @@ -10,6 +10,8 @@ import { import { HandleIdLookup } from '~/core'; import { IEventBus } from '~/core/events'; import { Privileges } from '../authorization'; +import { type ProjectListInput, type SecuredProjectList } from '../project/dto'; +import { ProjectService } from '../project/project.service'; import { UserService } from '../user'; import { type CreateFieldZone, @@ -28,6 +30,7 @@ export class FieldZoneService { private readonly events: IEventBus, private readonly users: UserService, private readonly repo: FieldZoneRepository, + private readonly projectService: ProjectService, ) {} async create(input: CreateFieldZone): Promise { @@ -115,4 +118,29 @@ export class FieldZoneService { items: results.items.map((dto) => this.secure(dto)), }; } + + async listProjects( + fieldZone: FieldZone, + input: ProjectListInput, + ): Promise { + const projectListOutput = await this.projectService.list({ + ...input, + filter: { + ...input.filter, + fieldRegion: { + ...(input.filter?.fieldRegion ?? {}), + fieldZone: { + ...(input.filter?.fieldRegion?.fieldZone ?? {}), + id: fieldZone.id, + }, + }, + }, + }); + + return { + ...projectListOutput, + canRead: true, + canCreate: false, // Field zone doesn't own project creation + }; + } } diff --git a/src/components/project/field-zone-project-connection.resolver.ts b/src/components/project/field-zone-project-connection.resolver.ts new file mode 100644 index 0000000000..e69de29bb2