Skip to content
Merged
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
1 change: 1 addition & 0 deletions .sqlfluff
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,5 @@ memberFunderShortCode = 'placeholder'
baseFieldShortCode = 'placeholder'
userKeycloakUserId = '00000000-0000-0000-0000-000000000000'
bulkUploadTaskId = 1
terminologySetId = 1
isCollaborative = false
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

## 0.40.0 2026-06-26

### Added

- Bulk uploads accept an optional `pdc_changemaker_id` column. When a row supplies a value in that column, the proposal is attached to that exact existing changemaker instead of matching/creating one by `organization_tax_id` + `organization_name`. An invalid or unknown id fails the task with a logged error.
- Added a `terminologySet` entity that lets funders define custom display labels for opportunities, proposals, and application forms, managed via the `/terminologySets` endpoints.
- Added a `terminologySet` permission scope, inherited from the owning funder, that governs who can view and edit terminology sets.
- Opportunities can now reference a terminology set owned by the same funder.
- Funders can now designate a default terminology set.

## 0.39.0 2026-06-24

Expand Down
20 changes: 20 additions & 0 deletions docs/ENTITY_RELATIONSHIP_DIAGRAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ erDiagram
int id PK
string title
string funderShortCode FK
int terminologySetId FK
datetime createdAt
uuid createdBy FK
}
Expand Down Expand Up @@ -119,6 +120,20 @@ erDiagram
string name
uuid keycloakOrganizationId
boolean isCollaborative
int defaultTerminologySetId FK
datetime createdAt
uuid createdBy FK
}
TerminologySet {
int id PK
string funderShortCode FK

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

At first glance there appears to be a circular reference (TerminologySet.funderShortCode and Funder.defaultTerminologySetId) but it is not really circular because there can be many TerminologySets created.

I wonder, though, if we even need the TerminologySet to be associated with a funderShortCode. Why should the definition of the terminology need to be associated with the funder? We have the user who created it, and perhaps the user can grant to the funder's organization the permissions to manage it. And we already have the defaultTerminologySetId on funder and also a terminologySetId on opportunity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah this is valid third normal form for sure; it isn't circular because default is nullable.

We do need a schema-level (not just permission) association between terminology sets and funders because terminology sets are a by funders. Users are not a proxy for organization at the schema level, since a user's association with any given organization is permission based rather than schema based.

It might help to think in terms of UX:

  1. When a funder creates an opportunity, they can choose a terminology set to use within that opportunity.
  2. The dropdown would show a list of all terminology sets associated with the funder (that they have permission to view).
  3. The default value of that dropdown would be driven by defaultTerminologySetId.

The user makes a selection, hits submit, and the opportunity is created.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've updated the API to remove any actual "use" of the default -- it is now just information for clients to leverage if they want.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The situation I am thinking of is this:

  1. A funder creates a terminology set with very commonly used terminology and sets it as default for its opportunities.
  2. Another funder cannot see that terminology set so creates an identical terminology set and sets it as default for its opportunities.

It seems to me that terminology (language) may be used by a particular organization but is not always particular to that organization.

We tend to avoid ownership in other cases so the alternative would be to use permissions (or lack thereof) rather than ownership. Like this:

  1. All terminology sets are always viewable by anyone.
  2. Authenticated users (or perhaps only users associated with funders) can create terminology sets.
  3. There is no funder associated with a terminology set.
  4. The user who creates the terminology set can manage it. Alternative: terminology sets are immutable.

The downside to this setup is change over time: a funder could not modify its existing terminology set and have that edit apply to all the things without going and touching all the things.

Therefore, having thought through it a bit more, it is not so bad to have the funder associated, with the exception that the funder's name will be visible to those who have access to that terminology set, when a funder may want to be a secret funder.

string name
string opportunityLabel
string opportunitiesLabel
string applicationFormLabel
string applicationFormsLabel
string proposalLabel
string proposalsLabel
datetime createdAt
uuid createdBy FK
}
Expand Down Expand Up @@ -179,6 +194,7 @@ erDiagram
int sourceId FK
int bulkUploadTaskId FK
int changemakerFieldValueId FK
int terminologySetId FK
string[] scope
string[] verbs
jsonb conditions
Expand All @@ -190,6 +206,9 @@ erDiagram
Opportunity ||--|{ ApplicationForm : establishes
Funder ||--|{ Opportunity : has
Funder ||--|{ BulkUploadTask : has
Funder ||--o{ TerminologySet : "authors"
Funder }o--o| TerminologySet : "defaults to"
Opportunity }o--o| TerminologySet : "uses"
ApplicationForm ||--|{ ApplicationFormField : has
ApplicationFormField }o--|| BaseField : represents
BaseField ||--o{ BaseFieldLocalization : "has localizations"
Expand Down Expand Up @@ -226,6 +245,7 @@ erDiagram
PermissionGrant }o--o| Source : "references"
PermissionGrant }o--o| BulkUploadTask : "references"
PermissionGrant }o--o| ChangemakerFieldValue : "references"
PermissionGrant }o--o| TerminologySet : "references"
PermissionGrant }o--|| User : "is created by"
Changemaker ||--o{ FiscalSponsorship : "sponsors"
Changemaker ||--o{ FiscalSponsorship : "is sponsored by"
Expand Down
8 changes: 8 additions & 0 deletions docs/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ or may be part of an organization's attributes returned by a data provider.

A person or group that makes change. For example, a grant applicant, grant
recipient, non-profit organization, or an individual seeking funds.

## Terminology set

A funder-authored bundle of display-label overrides for canonical PDC concepts
(e.g. "opportunity", "application form", "proposal"). A funder may publish many
terminology sets and pick one as the default that newly-created opportunities
adopt. Individual opportunities can also reference a terminology set explicitly.
A label left null falls back to the PDC default.
24 changes: 24 additions & 0 deletions docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ context key).
| edit | proposal | Create or update proposal versions for the funder's proposals |
| view | source | View the funder's sources |
| create | source | Create sources associated with the funder |
| view | terminologySet | View the funder's terminology sets |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am trying to imagine a case where a funder would like some other party to be able to view the funder but then not view the preferred terminology of that funder.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We could say that viewing a funder means viewing all of the funder's terminology sets, but it is starting to stray from the grant permission design philosophy.

Also remember this also covers the inverse (you can view the terminology set but not the funder)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oh, yes, that last capability makes sense.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Except, if we are using funder_short_code in the terminology set is that attribute filtered out on GET?

@slifty slifty Jun 15, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No -- that isn't the behavior across any of our FK relationships (nor should it be).

If you have the permission to view an entity you can view all of its attributes.

(now, you wouldn't see the actual OBJECT unless you have permission!)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than thinking in terms of the technical implementation (FK relationships) I'm thinking more in terms of UX: if you can view the terminology set but not the funder, we can't truly do that if the funder name is part of the terminology set.

| edit | terminologySet | Create or update terminology sets owned by the funder |
| manage | funder | View, send, and respond to funder collaborative invitations |
| | | View collaborative members for the funder |

Expand Down Expand Up @@ -325,6 +327,28 @@ not by a separate field-level permission.
| view | applicationForm | View the specific application form and its fields |
| edit | applicationForm | Update the application form (including its fields' labels, instructions, and input types via `PATCH /applicationFormFields/:id`) |

### TerminologySet Permissions

Permissions granted against a terminology set (using the terminology set's `id`
as the context key). Terminology sets are funder-scoped: an `edit |
terminologySet` grant on a funder automatically applies to all of that funder's
terminology sets. Opportunity-level grants do **not** confer access to
terminology sets — terminology sets inherit only from the parent funder.

Because opportunity responses embed the bound terminology set, the inlined
`terminologySet` is only populated when the caller has `view | terminologySet`
(directly or via the funder cascade). Callers without that permission still see
the opportunity's `terminologySetId`, but the inlined `terminologySet` is
`null`. This keeps the embed consistent with the rule above: viewing an
opportunity does not, by itself, grant access to its terminology set's details.

| Verb | Scope | What It Enables |
| --------- | -------------- | -------------------------------------------------------------------------------------------- |
| view | terminologySet | View the specific terminology set |
| edit | terminologySet | Update the terminology set's name and label fields |
| reference | terminologySet | Bind the terminology set to an opportunity (via `terminologySetId` on `POST /opportunities`) |
| manage | terminologySet | Full permissions including managing permission grants on the terminology set |

### Proposal Permissions

Permissions granted directly against a proposal (using the proposal's `id` as
Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/funders.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ describe('/funders', () => {
name: 'The Foundation Foundation',
keycloakOrganizationId: null,
isCollaborative: false,
defaultTerminologySetId: null,
},
{
shortCode: 'theFundFund',
Expand All @@ -130,6 +131,7 @@ describe('/funders', () => {
name: 'The Fund Fund',
keycloakOrganizationId: null,
isCollaborative: false,
defaultTerminologySetId: null,
},
systemFunder,
],
Expand Down Expand Up @@ -164,6 +166,7 @@ describe('/funders', () => {
name: 'Green Environment Fund',
keycloakOrganizationId: null,
isCollaborative: false,
defaultTerminologySetId: null,
},
],
});
Expand Down Expand Up @@ -371,6 +374,7 @@ describe('/funders', () => {
name: 'The Foundation Foundation',
keycloakOrganizationId: '0de87edc-be40-11ef-8249-0312f1b87538',
isCollaborative: false,
defaultTerminologySetId: null,
Comment thread
bickelj marked this conversation as resolved.
});
});

Expand Down Expand Up @@ -456,6 +460,7 @@ describe('/funders', () => {
createdAt: expectTimestamp(),
createdBy: testUser.keycloakUserId,
isCollaborative: false,
defaultTerminologySetId: null,
});
expect(after.count).toEqual(before.count);
expect(anotherFunderAfter).toEqual(anotherFunderBefore);
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/permissionGrants.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ describe('/permissionGrants', () => {
'changemakerFieldValueId',
9001,
],
[PermissionGrantEntityType.TERMINOLOGY_SET, 'terminologySetId', 9001],
] as Array<[PermissionGrantEntityType, string, number | string]>)(
'returns 404 to a non-admin when the %s context entity does not exist',
async (contextEntityType, keyName, keyValue) => {
Expand Down
Loading
Loading