Skip to content

feat(apify): one consolidated new-posts digest per scrape batch#760

Open
sweetmantech wants to merge 1 commit into
feat/scrape-alert-newness-difffrom
feat/scrape-digest-aggregation
Open

feat(apify): one consolidated new-posts digest per scrape batch#760
sweetmantech wants to merge 1 commit into
feat/scrape-alert-newness-difffrom
feat/scrape-digest-aggregation

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The digest architecture for recoupable/chat#1855 (PR #4 of 6, decision: one email per scrape, all platforms): registers each platform run under a batch_id at scrape start, records completions + genuinely-new post URLs in the webhook layer, and sends one consolidated digest when the batch's last run completes.

Stacked on api#759 (newness diff — provides filterNewPostUrls); requires database#41 (batch columns) applied before this deploys. Base is #759's branch so the diff shows only this PR's work; GitHub retargets to main when #759 merges.

What changed

  • lib/supabase/apify_scraper_runs/ (new): insertApifyScraperRuns (start-time registration), completeApifyScraperRun (completion + new URLs), selectApifyScraperRunsByBatch, selectApifyScraperRun, local row type (generated types don't carry database#41's columns yet).
  • lib/apify/digest/ (new): maybeSendScrapeDigest (the brain: all-siblings-complete gate → per-platform sections → skip-if-nothing-new), getScrapeDigestRecipients (same watcher chain the solo alert used), sendScrapeDigestEmail (BCC-only, deterministic minimal body — upgraded to the house template in PR Sweetmantech/myc 3550 apiimagegenerate use credits #5).
  • postArtistSocialsScrapeHandler: mints the batch, registers runs (with platform + social mapping via a new additive profileUrl on scrapeProfileUrlBatch results); response contract unchanged (extra field stripped).
  • apifyWebhookHandler: after each result handler, completes the run and runs the digest check; failures never fail the webhook. Schema now passes through the optional resource.id Apify already sends.
  • TikTok/Twitter handlers now compute + return newPostUrls (pre-persist diff); Instagram suppresses its solo email for batch-registered runs (legacy runs keep it).

Tests (RED→GREEN)

  • maybeSendScrapeDigest: 4 cases — waits for incomplete siblings; one send with per-platform sections (empty platforms omitted); nothing-new sends nothing; null batch no-ops.
  • Webhook: completion recorded with the handler's new URLs + digest check with the run's batch id; no-run-id skips bookkeeping.
  • Instagram: batch-registered run with new posts → no solo email, newPostUrls returned.
  • Sweeps: lib/apify + lib/artist + lib/socials272 tests passed; tsc --noEmit at exact baseline parity (200 pre-existing, 0 new); eslint clean.

Merge sequencing

database#41 → api#759 → this. E2E verification (real batch scrape → one digest) planned post-merge on the tracking issue since Apify webhooks call the deployed receiver.

🤖 Generated with Claude Code


Summary by cubic

Send one consolidated new‑posts digest per scrape batch across all platforms. Meets chat#1855 by batching runs, recording genuinely new URLs, and sending a single BCC email when the last run finishes.

  • New Features

    • Registers each Apify run under a batch in apify_scraper_runs; tracks completed_at and new_post_urls.
    • Webhook updates the run on completion and calls maybeSendScrapeDigest; never fails the webhook on digest errors.
    • New digest module: getScrapeDigestRecipients, maybeSendScrapeDigest, sendScrapeDigestEmail (per‑platform sections, BCC‑only).
    • TikTok/Twitter handlers return newPostUrls (diffed pre‑persist); Instagram suppresses solo email for batch runs (legacy runs unchanged).
    • postArtistSocialsScrapeHandler mints a batch_id, registers runs with platform/social mapping, and returns the same response shape.
  • Migration

    • Ensure apify_scraper_runs has the batch columns (batch_id, completed_at, new_post_urls) before deploying.
    • No API response changes for clients; behavior is additive and backward compatible.

Written for commit 9a991be. Summary will update on new commits.

Review in cubic

A roster scrape starts one Apify run per platform, each completing
independently — extending per-platform alerts would mean 4+ emails per
scrape. This registers every run under a batch_id at scrape start
(apify_scraper_runs, columns from recoupable/database#41), records each
webhook completion with its genuinely-new post URLs, and when the batch's
last run completes sends ONE digest (per-platform sections, BCC-only)
via the new digest module. Platforms with nothing new are omitted; a
batch with nothing new sends nothing. Instagram's solo alert is
suppressed for batch runs; legacy/non-batch runs keep today's behavior
(recoupable/chat#1855, PR #4 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 6, 2026 10:07pm

Request Review

@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • test

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 290f4c22-d662-4bb3-b037-8c9b5f40f407

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/scrape-digest-aggregation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

6 issues found across 21 files

Confidence score: 3/5

  • In lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts, query failures are converted to [], so postArtistSocialsScrapeHandler can treat a DB error as “no runs” and skip/suppress the digest path; this is the highest regression risk because failures become invisible—return/throw an explicit error (or status) and handle it upstream before merging.
  • lib/apify/digest/maybeSendScrapeDigest.ts can send the same consolidated digest multiple times when webhooks are replayed after batch completion, which can spam users and create inconsistent notifications—add an idempotent, atomic send gate (e.g., single update/check) before merge.
  • lib/apify/digest/getScrapeDigestRecipients.ts only reads the first page, so socials with >10,000 links can silently miss recipients and under-notify watchers—paginate or move this to a DB-side joined lookup so recipient selection is complete.
  • Current tests don’t cover key new wiring in lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts (batch registration flow) or the filterNewPostUrls integration in lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts, so regressions in digest-driving behavior are easier to ship—add focused assertions for those paths before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts">

<violation number="1" location="lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts:8">
P1: Silent error swallowing in batch query can suppress the digest. When the Supabase query fails, the function returns an empty array, making it indistinguishable from an empty batch. The caller treats `runs.length === 0` as "batch not ready" and returns null without re-raising or alerting — so a transient DB error on the last sibling's completion webhook silently drops the consolidated digest. Consider either propagating the error (so the caller can retry) or adding a dedicated recovery path (e.g., a queue retry or alert) for query failures.</violation>
</file>

<file name="lib/apify/digest/maybeSendScrapeDigest.ts">

<violation number="1" location="lib/apify/digest/maybeSendScrapeDigest.ts:26">
P2: A completed batch can send the consolidated digest more than once if the webhook for any registered run is delivered or replayed after all siblings are complete. Consider making the send gate idempotent with an atomic persisted marker/claim for the batch before calling `sendScrapeDigestEmail`.</violation>
</file>

<file name="lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts">

<violation number="1" location="lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts:5">
P2: The handler now calls `filterNewPostUrls` and includes `newPostUrls` in its return value (drives the digest email), but the tests don't verify that integration. Consider adding an assertion on the `filterNewPostUrls` call args and on `newPostUrls` in the return value so the mock isn't purely ornamental — the digest behavior depends on correct new-post tracking.</violation>
</file>

<file name="lib/apify/digest/getScrapeDigestRecipients.ts">

<violation number="1" location="lib/apify/digest/getScrapeDigestRecipients.ts:16">
P2: Digest recipients can be silently dropped for a social watched by more than 10,000 account links because this query reads only the first page. Consider paginating or moving this lookup into a DB-side joined query rather than encoding a larger fixed cap.

(Based on your team's feedback about DB-side pagination over raised Supabase limits.) [FEEDBACK_USED]</violation>
</file>

<file name="lib/apify/digest/sendScrapeDigestEmail.ts">

<violation number="1" location="lib/apify/digest/sendScrapeDigestEmail.ts:26">
P2: Post URLs and platform names are interpolated into the email HTML body without any escaping. Both the href attribute and visible text content for each link use the raw URL directly, and the platform name goes into an `<h3>` element. A URL containing a `"` would break the href attribute; a URL or platform with `<` or `>` could inject arbitrary HTML into the email. Consider encoding the URL for the href attribute (e.g., wrapping with a helper that escapes `&`, `"`, `<`, `>`) and optionally encoding visible text too, as a defense-in-depth measure.</violation>
</file>

<file name="lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts">

<violation number="1" location="lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts:12">
P1: The batch registration logic added to `postArtistSocialsScrapeHandler` (batchId generation, socialByUrl mapping, insertApifyScraperRuns call) has no test coverage in the handler's own test file. The test file was updated with the required mock and matching return shapes, but no `it(...)` block verifies the new behavior. This leaves the digest pipeline's registration step untested, so regressions — wrong account_id, wrong platform derivation, missing social_id, or a broken batchId — would go undetected in CI. Consider adding test cases that assert on the arguments passed to `insertApifyScraperRuns`, verify the response body omits profileUrl, and confirm the call is skipped in early-return paths (auth failure, 403, 402).</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant ScrapeHandler as postArtistSocialsScrapeHandler
    participant ScrapeBatch as scrapeProfileUrlBatch
    participant DB as Supabase DB
    participant Apify as Apify (external)
    participant WebhookHandler as apifyWebhookHandler
    participant ResultHandler as per‑platform result handler
    participant Digest as maybeSendScrapeDigest
    participant EmailService as digest email service

    rect rgb(240, 240, 240)
        Note over Client,ScrapeHandler: Phase 1 — Batch initialization
    end

    Client->>ScrapeHandler: POST /artist/socials/scrape (artist_id, socials)
    ScrapeHandler->>ScrapeBatch: CHANGED: scrapeProfileUrlBatch(socials)
    ScrapeBatch->>Apify: start scrape per social
    Apify-->>ScrapeBatch: runId, datasetId (per social)
    ScrapeBatch-->>ScrapeHandler: CHANGED: results include profileUrl
    ScrapeHandler->>DB: NEW: insertApifyScraperRuns(runs with batch_id, social_id, platform)
    alt registration succeeds
        DB-->>ScrapeHandler: ok
    else registration fails
        DB-->>ScrapeHandler: error (logged, scrape continues)
    end
    Note over ScrapeHandler: strips profileUrl from response (contract unchanged)
    ScrapeHandler-->>Client: 200 {runId, datasetId, error}[]

    rect rgb(245, 245, 245)
        Note over Apify,WebhookHandler: Phase 2 — Webhook processing (each run)
    end

    Apify->>WebhookHandler: POST /api/apify (webhook for run‑X)
    alt payload has no runId
        Note over WebhookHandler: skip digest bookkeeping (legacy path)
    else runId present
        WebhookHandler->>ResultHandler: dispatch per actor type
        Note over ResultHandler: e.g., handleInstagramProfileScraperResults
        alt Instagram with batch_id
            ResultHandler->>ResultHandler: NEW: suppress solo email (skip sendApifyWebhookEmail)
        end
        ResultHandler->>DB: NEW: filterNewPostUrls (pre‑persist diff)
        DB-->>ResultHandler: newPostUrls[]
        ResultHandler-->>WebhookHandler: CHANGED: {posts, newPostUrls, ...}
        WebhookHandler->>DB: NEW: completeApifyScraperRun(runId, newPostUrls)
        DB-->>WebhookHandler: updated row (with batch_id)
        WebhookHandler->>Digest: NEW: maybeSendScrapeDigest(batch_id)
        Digest->>DB: NEW: selectApifyScraperRunsByBatch(batch_id)
        DB-->>Digest: all runs in batch
        alt any sibling incomplete
            Note over Digest: wait for last run
            Digest-->>WebhookHandler: null (no email)
        else all completed
            alt at least one platform has new posts
                Digest->>EmailService: NEW: getScrapeDigestRecipients(socialIds)
                EmailService->>DB: selectAccountSocials → getAccountArtistIds → selectAccountEmails
                DB-->>EmailService: watcher email addresses
                EmailService-->>Digest: [watcher@...]
                Digest->>EmailService: NEW: sendScrapeDigestEmail(emails, sections)
                Note over EmailService: BCC‑only, per‑platform sections
                EmailService-->>Digest: email id
                Digest-->>WebhookHandler: email id
            else no new posts anywhere
                Note over Digest: skip (sends nothing)
                Digest-->>WebhookHandler: null
            end
        end
    end
    Note over WebhookHandler: Always return 200 (digest errors caught & logged)
    WebhookHandler-->>Apify: 200 OK
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

export async function selectApifyScraperRunsByBatch(
batchId: string,
): Promise<ApifyScraperRunRow[]> {
const { data, error } = await supabase

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Silent error swallowing in batch query can suppress the digest. When the Supabase query fails, the function returns an empty array, making it indistinguishable from an empty batch. The caller treats runs.length === 0 as "batch not ready" and returns null without re-raising or alerting — so a transient DB error on the last sibling's completion webhook silently drops the consolidated digest. Consider either propagating the error (so the caller can retry) or adding a dedicated recovery path (e.g., a queue retry or alert) for query failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts, line 8:

<comment>Silent error swallowing in batch query can suppress the digest. When the Supabase query fails, the function returns an empty array, making it indistinguishable from an empty batch. The caller treats `runs.length === 0` as "batch not ready" and returns null without re-raising or alerting — so a transient DB error on the last sibling's completion webhook silently drops the consolidated digest. Consider either propagating the error (so the caller can retry) or adding a dedicated recovery path (e.g., a queue retry or alert) for query failures.</comment>

<file context>
@@ -0,0 +1,17 @@
+export async function selectApifyScraperRunsByBatch(
+  batchId: string,
+): Promise<ApifyScraperRunRow[]> {
+  const { data, error } = await supabase
+    .from("apify_scraper_runs" as never)
+    .select("*")
</file context>

import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits";
import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits";

vi.mock("@/lib/supabase/apify_scraper_runs/insertApifyScraperRuns", () => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The batch registration logic added to postArtistSocialsScrapeHandler (batchId generation, socialByUrl mapping, insertApifyScraperRuns call) has no test coverage in the handler's own test file. The test file was updated with the required mock and matching return shapes, but no it(...) block verifies the new behavior. This leaves the digest pipeline's registration step untested, so regressions — wrong account_id, wrong platform derivation, missing social_id, or a broken batchId — would go undetected in CI. Consider adding test cases that assert on the arguments passed to insertApifyScraperRuns, verify the response body omits profileUrl, and confirm the call is skipped in early-return paths (auth failure, 403, 402).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts, line 12:

<comment>The batch registration logic added to `postArtistSocialsScrapeHandler` (batchId generation, socialByUrl mapping, insertApifyScraperRuns call) has no test coverage in the handler's own test file. The test file was updated with the required mock and matching return shapes, but no `it(...)` block verifies the new behavior. This leaves the digest pipeline's registration step untested, so regressions — wrong account_id, wrong platform derivation, missing social_id, or a broken batchId — would go undetected in CI. Consider adding test cases that assert on the arguments passed to `insertApifyScraperRuns`, verify the response body omits profileUrl, and confirm the call is skipped in early-return paths (auth failure, 403, 402).</comment>

<file context>
@@ -9,6 +9,9 @@ import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess
 import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits";
 import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits";
 
+vi.mock("@/lib/supabase/apify_scraper_runs/insertApifyScraperRuns", () => ({
+  insertApifyScraperRuns: vi.fn(async () => ({ data: null, error: null })),
+}));
</file context>

const emails = await getScrapeDigestRecipients(
runs.map(r => r.social_id).filter((id): id is string => Boolean(id)),
);
return await sendScrapeDigestEmail({ emails, sections });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A completed batch can send the consolidated digest more than once if the webhook for any registered run is delivered or replayed after all siblings are complete. Consider making the send gate idempotent with an atomic persisted marker/claim for the batch before calling sendScrapeDigestEmail.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/maybeSendScrapeDigest.ts, line 26:

<comment>A completed batch can send the consolidated digest more than once if the webhook for any registered run is delivered or replayed after all siblings are complete. Consider making the send gate idempotent with an atomic persisted marker/claim for the batch before calling `sendScrapeDigestEmail`.</comment>

<file context>
@@ -0,0 +1,27 @@
+  const emails = await getScrapeDigestRecipients(
+    runs.map(r => r.social_id).filter((id): id is string => Boolean(id)),
+  );
+  return await sendScrapeDigestEmail({ emails, sections });
+}
</file context>

import { handleTwitterProfileScraperResults } from "@/lib/apify/twitter/handleTwitterProfileScraperResults";
const listItems = vi.fn();
vi.mock("@/lib/socials/filterNewPostUrls", () => ({
filterNewPostUrls: vi.fn(async (urls: string[]) => urls),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The handler now calls filterNewPostUrls and includes newPostUrls in its return value (drives the digest email), but the tests don't verify that integration. Consider adding an assertion on the filterNewPostUrls call args and on newPostUrls in the return value so the mock isn't purely ornamental — the digest behavior depends on correct new-post tracking.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts, line 5:

<comment>The handler now calls `filterNewPostUrls` and includes `newPostUrls` in its return value (drives the digest email), but the tests don't verify that integration. Consider adding an assertion on the `filterNewPostUrls` call args and on `newPostUrls` in the return value so the mock isn't purely ornamental — the digest behavior depends on correct new-post tracking.</comment>

<file context>
@@ -1,6 +1,9 @@
 import { handleTwitterProfileScraperResults } from "@/lib/apify/twitter/handleTwitterProfileScraperResults";
 const listItems = vi.fn();
+vi.mock("@/lib/socials/filterNewPostUrls", () => ({
+  filterNewPostUrls: vi.fn(async (urls: string[]) => urls),
+}));
 vi.mock("@/lib/apify/client", () => ({ default: { dataset: vi.fn(() => ({ listItems })) } }));
</file context>


const accountSocials = (
await Promise.all(
uniqueSocialIds.map(socialId => selectAccountSocials({ socialId, limit: 10000 })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Digest recipients can be silently dropped for a social watched by more than 10,000 account links because this query reads only the first page. Consider paginating or moving this lookup into a DB-side joined query rather than encoding a larger fixed cap.

(Based on your team's feedback about DB-side pagination over raised Supabase limits.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/getScrapeDigestRecipients.ts, line 16:

<comment>Digest recipients can be silently dropped for a social watched by more than 10,000 account links because this query reads only the first page. Consider paginating or moving this lookup into a DB-side joined query rather than encoding a larger fixed cap.

(Based on your team's feedback about DB-side pagination over raised Supabase limits.) </comment>

<file context>
@@ -0,0 +1,28 @@
+
+  const accountSocials = (
+    await Promise.all(
+      uniqueSocialIds.map(socialId => selectAccountSocials({ socialId, limit: 10000 })),
+    )
+  ).flat();
</file context>

.map(
s =>
`<h3 style="margin:16px 0 4px">${s.platform}</h3><ul>${s.postUrls
.map(u => `<li><a href="${u.startsWith("http") ? u : `https://${u}`}">${u}</a></li>`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Post URLs and platform names are interpolated into the email HTML body without any escaping. Both the href attribute and visible text content for each link use the raw URL directly, and the platform name goes into an <h3> element. A URL containing a " would break the href attribute; a URL or platform with < or > could inject arbitrary HTML into the email. Consider encoding the URL for the href attribute (e.g., wrapping with a helper that escapes &, ", <, >) and optionally encoding visible text too, as a defense-in-depth measure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/sendScrapeDigestEmail.ts, line 26:

<comment>Post URLs and platform names are interpolated into the email HTML body without any escaping. Both the href attribute and visible text content for each link use the raw URL directly, and the platform name goes into an `<h3>` element. A URL containing a `"` would break the href attribute; a URL or platform with `<` or `>` could inject arbitrary HTML into the email. Consider encoding the URL for the href attribute (e.g., wrapping with a helper that escapes `&`, `"`, `<`, `>`) and optionally encoding visible text too, as a defense-in-depth measure.</comment>

<file context>
@@ -0,0 +1,38 @@
+    .map(
+      s =>
+        `<h3 style="margin:16px 0 4px">${s.platform}</h3><ul>${s.postUrls
+          .map(u => `<li><a href="${u.startsWith("http") ? u : `https://${u}`}">${u}</a></li>`)
+          .join("")}</ul>`,
+    )
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant