Skip to main content

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:
PatternWhen UsedExample
String ID reference1:1 or N:1 across collectionsexpense.projectId → projects/{id}
String array referenceN:M across collectionsproject.grantIds → activeGrants[]
Embedded sub-document1:N when the child is always read with the parent and the array is boundedexpense.allocations[], activeGrant.disbursementSchedule[]
Firestore subcollection1:N when the child list is unbounded or queried independentlyorganizations/{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)

Shared / platform-level

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 FieldLives OnSource Of TruthSync Mechanism
project.grantIds[]projectsactiveGrant.projectIds[]grantService + projectService co-write
activeGrant.projectIds[]activeGrantsproject.grantIds[]Same co-write
expense.grantIdexpensesexpense.allocations[0].grantIdConvenience field; set on write if single allocation
journalSubmission.totalHoursretrospectivesSum of timesheets entriesjournalService.submitMonth()
journalSubmission.entryIds[]retrospectivestimesheets where submissionId == idjournalService.submitMonth()
documentFolder.path[]documentFoldersParent chain traversaldocumentFolderService.create() — computed on write
contactSegment.memberCountcontactSegmentsCount of contacts matching criteriasegmentService scheduled evaluation
grantorRelationship.engagementLevelgrantorRelationshipscalculateEngagementScore() resultGrantorRelationshipService — updated on score change
task.projectIdtasksmilestone.projectIdtaskService.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.
CollectionFieldsDirectionUsed By
expensesorganizationId, status, createdAtASC, ASC, DESCExpense list views
expensesorganizationId, grantId, statusASC, ASC, ASCGrant expense reports
activeGrantsorganizationId, statusASC, ASCDashboard widgets
timesheetsorganizationId, userId, dateASC, ASC, DESCJournal views per user
complianceorganizationId, type, status, createdAtASC, ASC, ASC, DESCCompliance dashboard
auditLogsorganizationId, resourceType, resourceId, timestampASC, ASC, ASC, DESCEntity audit trails
documentsorganizationId, path, statusASC, ASC, ASCFolder subtree views
grantPipelineorganizationId, stage, updatedAtASC, ASC, DESCPipeline board