> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Group from a Query

> The end-to-end recipe for building VOMO Groups dynamically from query results — query users by criteria, manage Group population, maintain over time, and the snapshot-vs-self-updating distinction that shapes the architecture.

A common Volunteer integration pattern: define a group of users by some **criteria** rather than by manual selection — "all volunteers who served in 2024," "everyone from the Marketing department," "users who completed the food handler certification," "first-time volunteers from the past 90 days." VOMO Groups are perfect destinations for these query-derived collections, but Groups are **snapshots**, not self-updating dynamic queries. This recipe walks through the pattern: query criteria → user IDs → Group creation/update → ongoing maintenance.

The recipe combines [listing users with filters](/volunteer/workflows/list-users-with-filters), [Group management](/volunteer/workflows/manage-groups-and-members), and reconciliation into a complete query-to-Group workflow.

## What you'll build

A pattern for query-driven Groups that:

* Accepts a query specification (criteria for membership)
* Resolves the criteria to a set of VOMO User IDs
* Creates a Group populated with those Users (or updates an existing one)
* Maintains the Group over time via scheduled refreshes
* Tracks what changed between refreshes (audit + monitoring)
* Handles failure cases (deleted users, criteria changes, API failures)

## When this recipe fits

| Scenario                                                          | This recipe fits                                                               |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| "Volunteers who served in 2024" Group for reporting               | ✓                                                                              |
| "Marketing department" Group mirrored from HR system              | ✓ With external roster as the query source                                     |
| "Recent first-time volunteers" Group for onboarding workflows     | ✓                                                                              |
| "Holders of the Food Handler certification" Group for eligibility | ✓ With Certificate data                                                        |
| Truly dynamic "users matching X right now" Group                  | ✗ Groups are snapshots — see [the dynamic-query trap](#the-dynamic-query-trap) |

***

## The fundamental tension: snapshot vs. dynamic

Groups in VOMO are **persistent collections** — once you set members, those members stay until you explicitly change them. They're not dynamic queries that re-evaluate on read.

This means a "Group from a query" pattern has to choose:

| Pattern                          | Refresh model                                                  |
| -------------------------------- | -------------------------------------------------------------- |
| **Snapshot Group**               | Build once, accept that it ages                                |
| **Periodically refreshed Group** | Re-run the query daily/weekly/monthly, update members to match |
| **Event-driven refresh**         | Re-evaluate membership when triggering events occur            |

For most production integrations, **periodically refreshed** is the right pattern. Snapshots become stale; event-driven is hard to keep correct as edge cases accumulate.

### The dynamic-query trap

A common request from customers: "Can we have a Group that automatically includes anyone who serves in 2025?" The answer: yes, but it's a Group that **you** maintain on a schedule — not a Group that VOMO automatically updates.

The integration's value-add is the **schedule + criteria evaluation**. The customer sees "the Group is always current," but under the hood your integration is doing periodic re-population.

Set this expectation explicitly. A "self-updating Group" implies the integration is in the loop, not that VOMO has dynamic group functionality.

***

## Architecture

```mermaid theme={null}
graph TD
  Spec["Query specification<br/>(criteria + Group target)"]
  Scheduler["Refresh scheduler"]
  Resolver["Query resolver<br/>(criteria → User IDs)"]
  GroupMgr["Group manager<br/>(create/update Group)"]
  Diff["Diff calculator"]
  AuditLog["Refresh audit log"]

  Spec --> Scheduler
  Scheduler --> Resolver
  Resolver --> Diff
  Diff --> GroupMgr
  GroupMgr --> AuditLog
```

Five components:

| Component           | Purpose                                                            |
| ------------------- | ------------------------------------------------------------------ |
| Query specification | The criteria definition + target Group ID + refresh cadence        |
| Refresh scheduler   | Triggers re-evaluation on a schedule                               |
| Query resolver      | Translates criteria into a current set of User IDs                 |
| Group manager       | Creates the Group if needed; replaces members on each refresh      |
| Diff calculator     | Compares previous and current member sets; produces an audit trail |

***

## Step 1: define the query specification

Each query-driven Group has a stored specification:

```javascript JavaScript theme={null}
// Schema for query specifications
{
  id: 'spec-12345',
  customerId: 'cust-001',
  name: '2024 Active Volunteers',
  description: 'Anyone who served in 2024',
  criteria: {
    type: 'served_during',
    startDate: '2024-01-01',
    endDate: '2024-12-31',
  },
  vomoGroupId: 5678,       // Created on first refresh; updated on subsequent
  refreshCadence: 'daily', // or 'weekly', 'monthly', 'manual'
  createdAt: '2024-01-15T...',
  lastRefreshedAt: '2025-04-19T03:00:00Z',
  lastMemberCount: 247,
  enabled: true,
}
```

The criteria is structured — different criteria types call different resolver paths:

```javascript JavaScript theme={null}
const CRITERIA_TYPES = {
  served_during: {
    description: 'Users who served between two dates',
    fields: ['startDate', 'endDate'],
  },
  created_after: {
    description: 'Users created after a date',
    fields: ['date'],
  },
  email_domain: {
    description: 'Users with emails in a specific domain',
    fields: ['domain'],
  },
  has_certificate: {
    description: 'Users who have earned a specific Certificate',
    fields: ['certificateId'],
  },
  external_roster: {
    description: 'Users matching an external HR roster',
    fields: ['rosterSource'],
  },
  combined: {
    description: 'AND/OR of multiple criteria',
    fields: ['subCriteria', 'operator'],
  },
};
```

Each criterion type maps to a resolver function.

***

## Step 2: resolve the criteria to User IDs

```javascript JavaScript theme={null}
async function resolveCriteria(customerId, criteria) {
  switch (criteria.type) {
    case 'served_during':   return resolveServedDuring(customerId, criteria);
    case 'created_after':   return resolveCreatedAfter(customerId, criteria);
    case 'email_domain':    return resolveEmailDomain(customerId, criteria);
    case 'has_certificate': return resolveHasCertificate(customerId, criteria);
    case 'external_roster': return resolveExternalRoster(customerId, criteria);
    case 'combined':        return resolveCombined(customerId, criteria);
    default: throw new Error(`Unknown criteria type: ${criteria.type}`);
  }
}
```

### Resolver: "served during a time window"

The most common — and hardest, since participations aren't directly queryable. Walk active Projects and check each Project Date's participants:

```javascript JavaScript theme={null}
async function resolveServedDuring(customerId, criteria) {
  const startDate = new Date(criteria.startDate);
  const endDate = new Date(criteria.endDate);

  const userIds = new Set();

  // 1. Find all Projects with Dates in the window
  const params = new URLSearchParams({
    dates_after: startDate.toISOString(),
    dates_before: endDate.toISOString(),
    published: 'true',
  });

  const projects = await paginate(
    `https://api.vomo.org/v1/projects?${params}`,
    customerId
  );

  // 2. For each Project, walk its Dates in the window
  for (const projectSummary of projects) {
    const project = await getProjectDetail(projectSummary.id, customerId);
    const relevantDates = (project.all_dates ?? []).filter((d) => {
      const dStart = new Date(d.starts_at);
      return dStart >= startDate && dStart <= endDate;
    });

    for (const date of relevantDates) {
      const dateDetail = await getProjectDate(date.id, customerId);
      for (const participant of dateDetail.data?.participants ?? []) {
        userIds.add(participant.id);
      }
    }
  }

  return [...userIds];
}
```

**Cost:** Substantial. For an active customer, this is many API requests per resolution. Cache the intermediate results (Project detail, Project Date detail) and pace the resolver.

### Resolver: "users created after a date"

Much cheaper — single filter on `/users`:

```javascript JavaScript theme={null}
async function resolveCreatedAfter(customerId, criteria) {
  const params = new URLSearchParams({ created_after: criteria.date });
  const users = await paginate(
    `https://api.vomo.org/v1/users?${params}`,
    customerId
  );
  return users.map((u) => u.id);
}
```

### Resolver: "email domain"

```javascript JavaScript theme={null}
async function resolveEmailDomain(customerId, criteria) {
  const params = new URLSearchParams({ email_like: `@${criteria.domain}` });
  const matches = await paginate(
    `https://api.vomo.org/v1/users?${params}`,
    customerId
  );

  // Secondary filter — email_like is substring; we want exact domain match
  return matches
    .filter((u) => u.email?.toLowerCase().endsWith(`@${criteria.domain.toLowerCase()}`))
    .map((u) => u.id);
}
```

### Resolver: "has certificate"

<Note>
  The earned-certificate data shape on `UserDetailResource` is undocumented in the OpenAPI spec. The resolver below assumes an `earned_certificates` array on the detail response. Confirm against live data before relying on it.
</Note>

```javascript JavaScript theme={null}
async function resolveHasCertificate(customerId, criteria) {
  const allUsers = await paginate('https://api.vomo.org/v1/users', customerId);

  const matched = [];
  for (const user of allUsers) {
    const detail = await getUserDetail(user.id, customerId);
    const earned = detail.earned_certificates ?? []; // confirm field name
    if (earned.some((c) => c.certificate_id === criteria.certificateId)) {
      matched.push(user.id);
    }
  }
  return matched;
}
```

This is N+1. For large accounts, consider caching the certificate-by-user mapping in your integration's state DB.

### Resolver: "external roster"

```javascript JavaScript theme={null}
async function resolveExternalRoster(customerId, criteria) {
  const externalRecords = await fetchExternalRoster(customerId, criteria.rosterSource);

  const vomoIds = [];
  for (const externalRecord of externalRecords) {
    const user = await findUserByEmail(externalRecord.email, customerId);
    if (user) {
      vomoIds.push(user.id);
    } else {
      // Optionally upsert into VOMO
      const result = await upsertVomoUser(customerId, externalRecord);
      vomoIds.push(result.user.id);
    }
  }
  return vomoIds;
}
```

The find-or-create-then-add pattern: external system is authoritative for membership; VOMO is the destination.

### Resolver: combined criteria

```javascript JavaScript theme={null}
async function resolveCombined(customerId, criteria) {
  const sets = await Promise.all(
    criteria.subCriteria.map((sub) => resolveCriteria(customerId, sub))
  );

  if (criteria.operator === 'AND') {
    return sets.reduce((acc, ids) => {
      const idSet = new Set(ids);
      return acc.filter((id) => idSet.has(id));
    });
  }

  if (criteria.operator === 'OR') {
    const union = new Set();
    for (const ids of sets) for (const id of ids) union.add(id);
    return [...union];
  }

  throw new Error(`Unknown combine operator: ${criteria.operator}`);
}
```

AND and OR cover most needs. NOT is typically expressed as "in set A but not in set B" — a derived combination.

***

## Step 3: create or update the Group

```javascript JavaScript theme={null}
async function refreshGroupFromQuery(customerId, specId) {
  const spec = await db.getQuerySpec(customerId, specId);
  if (!spec.enabled) return { skipped: true, reason: 'disabled' };

  const traceId = generateTraceId();
  await jobLog.start(customerId, 'group_refresh', { specId, traceId });

  try {
    // 1. Resolve the criteria
    const targetUserIds = await resolveCriteria(customerId, spec.criteria);

    // 2. Determine create vs. update
    let previousMembers = [];
    if (!spec.vomoGroupId) {
      // First refresh — create the Group
      const created = await createGroup(customerId, {
        name: spec.name,
        description: spec.description,
        members: targetUserIds,
      });
      spec.vomoGroupId = created.data.id;
      await db.saveQuerySpec(customerId, spec);
    } else {
      // Existing Group — get current members for diff
      previousMembers = (await getGroupMembers(spec.vomoGroupId, customerId))
        .map((m) => m.id);

      await replaceGroupMembers(customerId, spec.vomoGroupId, targetUserIds);
    }

    // 3. Compute diff
    const previousSet = new Set(previousMembers);
    const currentSet = new Set(targetUserIds);
    const added = targetUserIds.filter((id) => !previousSet.has(id));
    const removed = previousMembers.filter((id) => !currentSet.has(id));

    // 4. Audit log
    await db.insert('group_refresh_audit', {
      customer_id: customerId,
      spec_id: specId,
      vomo_group_id: spec.vomoGroupId,
      previous_count: previousMembers.length,
      new_count: targetUserIds.length,
      added_count: added.length,
      removed_count: removed.length,
      added_user_ids: added,
      removed_user_ids: removed,
      trace_id: traceId,
      refreshed_at: new Date(),
    });

    // 5. Update spec
    await db.upsert('query_specs', {
      id: specId,
      last_refreshed_at: new Date(),
      last_member_count: targetUserIds.length,
    });

    await jobLog.complete(customerId, 'group_refresh', {
      specId,
      traceId,
      result: {
        memberCount: targetUserIds.length,
        addedCount: added.length,
        removedCount: removed.length,
      },
    });

    return {
      vomoGroupId: spec.vomoGroupId,
      memberCount: targetUserIds.length,
      added: added.length,
      removed: removed.length,
    };
  } catch (err) {
    await jobLog.fail(customerId, 'group_refresh', { specId, traceId, error: err.message });
    throw err;
  }
}
```

The audit log captures **what changed at each refresh** — useful for customer-facing transparency and debugging.

***

## Step 4: schedule the refresh

```javascript JavaScript theme={null}
async function scheduleGroupRefreshes() {
  const allActiveSpecs = await db.getAllActiveQuerySpecs();

  for (const spec of allActiveSpecs) {
    switch (spec.refreshCadence) {
      case 'hourly':
        await scheduler.cron(`group_refresh:${spec.id}`, '0 * * * *',
          () => refreshGroupFromQuery(spec.customerId, spec.id));
        break;
      case 'daily':
        await scheduler.cron(`group_refresh:${spec.id}`, '0 3 * * *',
          () => refreshGroupFromQuery(spec.customerId, spec.id));
        break;
      case 'weekly':
        await scheduler.cron(`group_refresh:${spec.id}`, '0 3 * * 0',
          () => refreshGroupFromQuery(spec.customerId, spec.id));
        break;
      case 'monthly':
        await scheduler.cron(`group_refresh:${spec.id}`, '0 3 1 * *',
          () => refreshGroupFromQuery(spec.customerId, spec.id));
        break;
      case 'manual':
        // No schedule — refresh only on explicit trigger
        break;
    }
  }
}
```

### Right-sizing cadence by criteria type

| Criteria                                      | Suggested cadence                             |
| --------------------------------------------- | --------------------------------------------- |
| "Created after X" (static date)               | Daily — new users added; never removed        |
| "Email domain"                                | Daily — emails change rarely                  |
| "Served during 2024" (historical window)      | Weekly — window is closed                     |
| "Served in the last 90 days" (rolling window) | Daily — window moves forward each day         |
| "Has Certificate X"                           | Daily — certificates earned periodically      |
| "External roster"                             | Hourly to daily depending on source frequency |

Match cadence to how often the underlying data actually changes. Hourly refresh of "users with @wayne.example email" is wasteful.

***

## Step 5: customer-facing visibility

```javascript JavaScript theme={null}
async function getGroupHealth(customerId, specId) {
  const spec = await db.getQuerySpec(customerId, specId);
  const recent = await db.getRecentRefreshes(customerId, specId, 30);

  return {
    spec: {
      name: spec.name,
      description: spec.description,
      criteria: spec.criteria,
      refreshCadence: spec.refreshCadence,
    },
    currentState: {
      vomoGroupId: spec.vomoGroupId,
      memberCount: spec.lastMemberCount,
      lastRefreshedAt: spec.lastRefreshedAt,
      ageMinutes: Math.round((Date.now() - spec.lastRefreshedAt.getTime()) / 60000),
    },
    recentHistory: recent.map((r) => ({
      refreshedAt: r.refreshed_at,
      memberCount: r.new_count,
      delta: r.new_count - r.previous_count,
      added: r.added_count,
      removed: r.removed_count,
    })),
  };
}
```

The history shows "47 yesterday; 52 today; +6 new; -1 removed" — a clear activity signal.

***

## Common queries with full specifications

### Volunteers who served in the last 90 days

```javascript JavaScript theme={null}
const spec = {
  name: 'Recent Active Volunteers',
  description: 'Anyone who served in the past 90 days',
  criteria: {
    type: 'served_during',
    startDate: () => daysAgo(90).toISOString(),
    endDate: () => new Date().toISOString(),
  },
  refreshCadence: 'daily',
};
```

The dynamic `startDate` re-evaluates at each refresh — the window moves forward each day.

### First-time volunteers in 2025

```javascript JavaScript theme={null}
const spec = {
  name: 'First-Time Volunteers (2025)',
  description: 'Users created in 2025 with at least one participation',
  criteria: {
    type: 'combined',
    operator: 'AND',
    subCriteria: [
      { type: 'created_after', date: '2025-01-01' },
      {
        type: 'served_during',
        startDate: '2025-01-01',
        endDate: () => new Date().toISOString(),
      },
    ],
  },
  refreshCadence: 'daily',
};
```

### External HR roster sync

```javascript JavaScript theme={null}
const spec = {
  name: 'Marketing Department Volunteers',
  description: 'Marketing dept employees, mirrored from Workday',
  criteria: {
    type: 'external_roster',
    rosterSource: 'workday_marketing_department',
  },
  refreshCadence: 'hourly',
};
```

***

## Handling failures

### Resolver failures

```javascript JavaScript theme={null}
async function refreshGroupFromQueryWithFailureHandling(customerId, specId) {
  try {
    return await refreshGroupFromQuery(customerId, specId);
  } catch (err) {
    await db.upsert('query_specs', {
      id: specId,
      last_refresh_failed_at: new Date(),
      last_refresh_error: err.message,
    });

    await alertOps({
      severity: 'medium',
      customerId,
      specId,
      type: 'group_refresh_failed',
      error: err.message,
    });

    // Don't auto-retry — queries may be expensive; surface for investigation
    throw err;
  }
}
```

### Detecting deleted users in resolved sets

If the resolver returns User IDs that no longer exist in VOMO, the PUT to Group members will fail validation. Filter before pushing:

```javascript JavaScript theme={null}
async function filterValidUserIds(customerId, userIds) {
  const valid = [];
  const invalid = [];

  for (const id of userIds) {
    const exists = await checkUserExists(customerId, id);
    if (exists) valid.push(id);
    else invalid.push(id);
  }

  if (invalid.length > 0) {
    await alertOps({
      severity: 'low',
      customerId,
      type: 'deleted_users_in_query',
      invalidCount: invalid.length,
    });
  }

  return valid;
}
```

For large user lists, this is expensive. Alternative: don't pre-filter; let the PUT fail; capture errors and retry without the missing IDs.

***

## Things to watch for

### The resolver determines the cost

"users created after 2024-01-01" = one paginated query. "served during 2024" = many Project Date fetches. Choose the criteria type with the cost in mind.

### Member churn signals issues

If a Group's member count swings dramatically between refreshes (247 yesterday, 12 today), something is wrong:

* The query criteria changed unexpectedly
* The underlying data changed (mass deletion, organization restructure)
* The resolver has a bug

Alert on >50% swing in member count between consecutive refreshes.

### Group hierarchy considerations

If the target Group has a `parent_id` (is part of a hierarchy), the refresh shouldn't disturb that. The PUT to `/groups/{id}/members` replaces only the member list — but PUT to `/groups/{id}` (for metadata) requires preserving `parent_id`. Use the [GET-then-PUT pattern](/volunteer/workflows/manage-groups-and-members#phase-2-update-group-metadata).

### Email-based external rosters and the email-change problem

For external-roster criteria, email matches users. If an email changes externally but not in VOMO (or vice versa), the user may disappear from the Group on the next refresh.

Track external IDs separately from emails. See [The email-change problem](/volunteer/workflows/create-or-update-a-user#the-email-change-problem).

### Rate-limit pressure during resolution

Resolvers like `served_during` make many API calls. For partner integrations refreshing many specs simultaneously, stagger the schedule:

```javascript JavaScript theme={null}
async function staggeredRefreshAllSpecs() {
  const specs = await db.getActiveSpecs();
  for (let i = 0; i < specs.length; i++) {
    setTimeout(
      () => refreshGroupFromQuery(specs[i].customerId, specs[i].id),
      i * 30 * 1000 // 30 seconds apart
    );
  }
}
```

***

## What you've built

After this recipe:

* ✅ A query specification model (criteria + target Group + cadence)
* ✅ Resolver functions for common criteria types
* ✅ A refresh pipeline that creates/updates Groups idempotently
* ✅ A diff calculator producing per-refresh audit data
* ✅ Scheduled refresh with right-sized cadences
* ✅ Customer-facing visibility into refresh history
* ✅ Failure handling that surfaces problems for review

This is the foundation for any "Group from query" workflow — historical-volunteer Groups, eligibility Groups, department-mirror Groups.

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Report on Volunteer Hours" icon="clock" href="/volunteer/recipes/report-on-volunteer-hours">
    The reporting recipe using participation data.
  </Card>

  <Card title="Combine Volunteer Data with CRM+ Data" icon="layer-group" href="/volunteer/recipes/combine-volunteer-data-with-crm-plus-data">
    The cross-API recipe stitching Volunteer with CRM+.
  </Card>

  <Card title="Manage Groups and Members" icon="users-gear" href="/volunteer/workflows/manage-groups-and-members">
    The workflow page this recipe builds on.
  </Card>

  <Card title="Sync Users to External System" icon="arrows-rotate" href="/volunteer/recipes/sync-users-to-external-system">
    The companion recipe — user sync as the foundation.
  </Card>
</CardGroup>
