Documentation Index
Fetch the complete documentation index at: https://grantmaster.dev/llms.txt
Use this file to discover all available pages before exploring further.
Collection Relationships
This document describes how GrantMaster’s Firestore collections relate to each other: foreign-key conventions, embedded vs. referenced sub-documents, denormalization decisions, and the query patterns that arise from them.
For entity-level field definitions see docs/product/domain/domain-model.md.
For raw collection schemas see firestore-schemas.md.
Foreign Key Conventions
Firestore has no native joins. GrantMaster uses three reference patterns:
| Pattern | When Used | Example |
|---|
| String ID reference | 1:1 or N:1 across collections | expense.projectId → projects/{id} |
| String array reference | N:M across collections | project.grantIds → activeGrants[] |
| Embedded sub-document | 1:N when the child is always read with the parent and the array is bounded | expense.allocations[], activeGrant.disbursementSchedule[] |
| Firestore subcollection | 1:N when the child list is unbounded or queried independently | organizations/{id}/moduleInstallations/{moduleId} |
All string ID foreign keys use the target document’s Firestore document ID (the id field). There are no surrogate integer keys.
Cross-Collection Reference Map
Core tenant graph
organizations/{orgId}
├─ employees/{userId} (members)
├─ invitations/{invitationId} (pending invites)
├─ projects/{projectId}
│ ├─ budgetItems/{budgetItemId} (subcollection)
│ ├─ milestones/{milestoneId} (subcollection)
│ └─ tasks/{taskId} (subcollection) [denorm: projectId stored on task]
├─ activeGrants/{grantId}
│ └─ disbursementSchedule[] (embedded array)
├─ expenses/{expenseId}
│ └─ allocations[] (embedded array — each references an activeGrant)
├─ timesheets/{journalEntryId} (JournalEntry docs)
├─ retrospectives/{submissionId} (JournalSubmission docs)
├─ compliance/{ruleId}
├─ auditLogs/{logId}
├─ contacts/{contactId}
├─ documents/{documentId}
├─ documentFolders/{folderId}
└─ organizations/{orgId}/moduleInstallations/{moduleId} (subcollection)
foundations/{foundationId} (shared funder library — org-readable, admin-writeable)
grantOpportunities/{opportunityId} (org-scoped)
grantPipeline/{pipelineId} (org-scoped, refs opportunityId)
grantApplications/{applicationId} (org-scoped, refs pipelineId)
partnerOrganizations/{partnerId} (platform-level)
referrals/{referralId} (platform-level)
missionCredits/{orgId} (one doc per org — orgId IS the document ID)
creditTransactions/{txId} (org-scoped)
subscriptions/{subscriptionId} (org-scoped)
widgetDefinitions/{widgetId} (org-scoped after extension install)
widgetAssignments/{assignmentId} (user-scoped)
Visual Collection Relationships
The following ER diagram shows the primary foreign-key relationships between Firestore collections. Annotations on each relationship indicate the field that holds the reference.
Collection-name gotchas: Several collection names differ from their logical domain:
employees stores User documents (not just employees)
timesheets stores JournalEntry documents (the “journals” feature)
retrospectives stores MonthlyJournalSubmission documents
Visual Collection Relationships
The following ER diagram shows the primary foreign-key relationships between Firestore collections. Annotations on each relationship indicate the field that holds the reference.
Collection-name gotchas: Several collection names differ from their logical domain:
employees stores User documents (not just employees)
timesheets stores JournalEntry documents (the “journals” feature)
retrospectives stores MonthlyJournalSubmission documents
Key Relationships in Detail
Project ↔ ActiveGrant (N:M)
Projects and grants are many-to-many. A project may be co-funded by multiple grants; a grant may fund multiple projects.
project.grantIds: string[] — array of activeGrants IDs on the Project document.
activeGrant.projectIds: string[] — mirrored array on the ActiveGrant document.
Denormalization decision: Both sides carry the reference array to avoid collection-group queries when loading either entity. The service layer (grantService, projectService) keeps them in sync on write.
Query pattern:
// Projects funded by a grant
const projects = await getDocs(
query(collection(db, 'projects'), where('grantIds', 'array-contains', grantId))
);
// Grants funding a project
const grants = await getDocs(
query(collection(db, 'activeGrants'), where('projectIds', 'array-contains', projectId))
);
Expense ↔ ActiveGrant (allocation pattern)
An expense can be split across multiple grants via the embedded allocations array. Each allocation references an activeGrant ID and carries its own amount and percentage.
expense {
projectId: string → projects
grantId: string | null → activeGrants (primary grant, for single-allocation cases)
allocations: [
{ grantId, amount, percentage }, → activeGrants
{ grantId, amount, percentage },
]
}
Why embedded: Allocations are always read with the expense, are bounded in practice (rarely more than 3–4 per expense), and do not need to be queried independently.
Constraint: sum(allocations[].percentage) === 100 enforced by Zod schema ExpenseSchema before write.
JournalEntry ↔ JournalSubmission
Each JournalEntry (stored in timesheets) may belong to at most one JournalSubmission (stored in retrospectives). The link is held on the entry:
timesheets/{entryId} {
submissionId: string | null → retrospectives/{submissionId}
}
retrospectives/{submissionId} {
entryIds: string[] → timesheets[] (denormalized for batch reads)
}
Denormalization decision: entryIds on the submission enables a single getAll(entryIds) batch read when rendering a monthly summary, avoiding a where('submissionId', '==', id) query on a potentially large collection.
Document ↔ DocumentFolder (self-referencing tree)
Folders can nest arbitrarily.
documentFolders/{folderId} {
parentId: string | null → documentFolders/{parentId}
path: string[] // ancestor IDs ordered from root (denormalized)
}
Why path array: Firestore does not support recursive ancestor queries. Storing the full ancestor path allows breadcrumb rendering and subtree queries (where('path', 'array-contains', folderId)) without recursive reads.
AuditLog ↔ everything
auditLogs is a write-once collection. Every significant action across all domains writes here. The log is correlated back to source entities by (resourceType, resourceId).
auditLogs/{logId} {
resourceType: string // e.g. 'expense', 'project', 'user'
resourceId: string // ID in that resource's collection
userId: string → employees
organizationId: string → organizations
action: AuditAction // enum of 80+ constants
}
Query pattern for entity audit trail:
const logs = await queryAuditLogs({
organizationId,
resourceType: 'expense',
resourceId: expenseId,
});
MissionCredit (singleton per org)
The credit wallet uses the organizationId as its document ID, making it a deterministic singleton — no lookup query needed.
const creditRef = doc(db, 'missionCredits', organizationId);
Credit history is in creditTransactions (separate collection, org-scoped).
ModuleInstallation (org subcollection)
Extension installations are stored as a subcollection directly under the organization document. The moduleId (e.g. grant-calendar) is the document ID.
organizations/{orgId}/moduleInstallations/{moduleId} {
status: 'installing' | 'active' | 'error' | 'deactivated'
version: string
config: Record<string, unknown>
}
Why subcollection: Prevents namespace collisions between orgs; allows Firestore security rules to scope reads/writes to the owning org without a where clause.
Denormalization Summary
| Denormalized Field | Lives On | Source Of Truth | Sync Mechanism |
|---|
project.grantIds[] | projects | activeGrant.projectIds[] | grantService + projectService co-write |
activeGrant.projectIds[] | activeGrants | project.grantIds[] | Same co-write |
expense.grantId | expenses | expense.allocations[0].grantId | Convenience field; set on write if single allocation |
journalSubmission.totalHours | retrospectives | Sum of timesheets entries | journalService.submitMonth() |
journalSubmission.entryIds[] | retrospectives | timesheets where submissionId == id | journalService.submitMonth() |
documentFolder.path[] | documentFolders | Parent chain traversal | documentFolderService.create() — computed on write |
contactSegment.memberCount | contactSegments | Count of contacts matching criteria | segmentService scheduled evaluation |
grantorRelationship.engagementLevel | grantorRelationships | calculateEngagementScore() result | GrantorRelationshipService — updated on score change |
task.projectId | tasks | milestone.projectId | taskService.create() — copied from milestone |
Common Query Patterns
1. Dashboard: active grants with budget burn
// All active grants for an org with their linked project budgets
const grants = await getDocs(
query(collection(db, 'activeGrants'),
where('organizationId', '==', orgId),
where('status', '==', 'active'))
);
// Then batch-read linked projects
const projectIds = grants.flatMap(g => g.data().projectIds);
const projects = await getAll(projectIds.map(id => doc(db, 'projects', id)));
2. Expense report: expenses by grant
// Direct grant allocation (single-grant expenses)
const direct = await getDocs(
query(collection(db, 'expenses'),
where('organizationId', '==', orgId),
where('grantId', '==', grantId),
where('status', '==', 'approved'))
);
// Split allocations (multi-grant expenses)
// NOTE: Firestore cannot query inside embedded arrays on a nested field.
// These are resolved client-side by reading all approved expenses for the org
// and filtering: expense.allocations.some(a => a.grantId === grantId)
Known limitation: The allocation array pattern requires a client-side filter pass for multi-grant queries. At scale, consider a denormalized grantIds[] array on Expense (mirrors the project ↔ grant pattern).
3. Journal submission review: load all entries for a month
const submission = await getDoc(doc(db, 'retrospectives', submissionId));
const entryIds = submission.data().entryIds;
// Single batch read — no query needed because IDs are denormalized
const entries = await Promise.all(
entryIds.map(id => getDoc(doc(db, 'timesheets', id)))
);
4. Compliance dashboard: open alerts for org
const alerts = await getDocs(
query(collection(db, 'compliance'),
where('organizationId', '==', orgId),
where('type', '==', 'alert'),
where('status', '==', 'open'),
orderBy('createdAt', 'desc'))
);
5. Document folder subtree
// All documents in a folder and all its descendants
const docs = await getDocs(
query(collection(db, 'documents'),
where('organizationId', '==', orgId),
where('path', 'array-contains', folderId)) // uses denormalized path[]
);
Firestore Index Requirements
The following composite indexes are required for the query patterns above. See firestore.indexes.json for the full list.
| Collection | Fields | Direction | Used By |
|---|
expenses | organizationId, status, createdAt | ASC, ASC, DESC | Expense list views |
expenses | organizationId, grantId, status | ASC, ASC, ASC | Grant expense reports |
activeGrants | organizationId, status | ASC, ASC | Dashboard widgets |
timesheets | organizationId, userId, date | ASC, ASC, DESC | Journal views per user |
compliance | organizationId, type, status, createdAt | ASC, ASC, ASC, DESC | Compliance dashboard |
auditLogs | organizationId, resourceType, resourceId, timestamp | ASC, ASC, ASC, DESC | Entity audit trails |
documents | organizationId, path, status | ASC, ASC, ASC | Folder subtree views |
grantPipeline | organizationId, stage, updatedAt | ASC, ASC, DESC | Pipeline board |