DEV Community

LunarDrift
LunarDrift

Posted on

Your Community Showcase Needs a Work Queue, Not Another Chat Channel

An enthusiastic community prompt can quietly become operational debt.

You invite people to nominate work for a showcase. The first few replies feel manageable. Then submissions keep arriving, reviewers assume somebody else owns them, and authors cannot tell whether the original promise still stands.

The uncomfortable part is not that the community participated too much. It is that a conversational promise became a queue without acquiring queue semantics.

Creating another channel rarely fixes this. It moves the same ambiguity somewhere fewer people can see it. A more reliable design keeps the existing community conversation as the surface while adding a small commitment ledger behind it.

In this tutorial, we will build that ledger in TypeScript. It will provide:

  • bounded admission, so the application cannot accept unlimited work;
  • one nomination per author, with idempotent retries;
  • explicit campaign states for accepting, draining, and closing;
  • reviewer leases, so every accepted item has temporary ownership;
  • protection against late reviewers completing reassigned work;
  • verifiable progress independent of chat-notification delivery.

Tencent RTC's Social Messaging solution covers the relevant conversation surfaces, including group discussions, communities, rich media, and interest-based social experiences. We will treat that messaging layer as the place where people discover and interact with the showcase—not as the database that decides whether a promise has been fulfilled.

First decide whether you need this machinery

Not every community prompt needs a workflow service.

Situation Better choice
A casual request with no completion promise Keep it as a normal thread
A small, time-boxed prompt owned by one moderator Use a pinned checklist and publish a closing update
Every accepted submission must receive an action Use a commitment ledger
Several reviewers can claim work Use leases or another explicit assignment mechanism
The organizer needs to stop intake while finishing accepted work Separate open from draining

The important distinction is conversation versus commitment. Messages can arrive in any order and may be missed. A commitment requires admission rules, ownership, terminal states, and a definition of done.

The lifecycle users should be able to see

Our campaign has three states:

open -----> draining -----> closed
  |             |
  |             +-- no new submissions
  +-- accepts work while capacity and deadline allow
Enter fullscreen mode Exit fullscreen mode

Each accepted entry follows a separate lifecycle:

queued -----> leased -----> done
   |              |
   |              +-------> queued    (lease expires)
   |
   +----------------------> withdrawn
Enter fullscreen mode Exit fullscreen mode

draining matters because “we stopped accepting submissions” is not the same as “we completed the promise.” Closing the campaign while entries remain queued would hide unfinished work.

A reviewer claim is a lease rather than permanent ownership. If a browser closes or a worker crashes, the entry becomes claimable after the lease expires. Each lease receives a token, preventing the original reviewer from submitting a late result after somebody else has taken over.

Create the reproducible project

Use Node.js 20 or later:

mkdir community-promise-ledger
cd community-promise-ledger
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src
Enter fullscreen mode Exit fullscreen mode

Add the test command to package.json:

{
  "scripts": {
    "test": "tsx src/ledger.test.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now create src/ledger.ts:

import { randomUUID } from "node:crypto";

export type CampaignState = "open" | "draining" | "closed";
export type EntryState = "queued" | "leased" | "done" | "withdrawn";

export type Campaign = {
  id: string;
  state: CampaignState;
  capacity: number;
  acceptedCount: number;
  closesAt: number;
};

export type Lease = {
  token: string;
  reviewerId: string;
  expiresAt: number;
};

export type Entry = {
  id: string;
  campaignId: string;
  authorId: string;
  sourceConversationId: string;
  sourceMessageId: string;
  title: string;
  url: string;
  state: EntryState;
  createdAt: number;
  lease?: Lease;
  reviewSummary?: string;
  completedBy?: string;
};

export class LedgerError extends Error {
  constructor(public readonly code: string) {
    super(code);
  }
}

export class CommitmentLedger {
  private campaigns = new Map<string, Campaign>();
  private entries = new Map<string, Entry>();

  constructor(private readonly now: () => number = Date.now) {}

  createCampaign(input: {
    id: string;
    capacity: number;
    closesAt: number;
  }): Campaign {
    if (input.capacity < 1) throw new LedgerError("INVALID_CAPACITY");
    if (this.campaigns.has(input.id)) {
      throw new LedgerError("CAMPAIGN_EXISTS");
    }

    const campaign: Campaign = {
      ...input,
      state: "open",
      acceptedCount: 0,
    };

    this.campaigns.set(campaign.id, campaign);
    return structuredClone(campaign);
  }

  submit(input: {
    campaignId: string;
    authorId: string;
    sourceConversationId: string;
    sourceMessageId: string;
    title: string;
    url: string;
  }): Entry {
    const campaign = this.requireCampaign(input.campaignId);

    const previous = [...this.entries.values()].find(
      entry =>
        entry.campaignId === input.campaignId &&
        entry.authorId === input.authorId,
    );

    // A repeated delivery of the same message returns the original receipt.
    if (previous?.sourceMessageId === input.sourceMessageId) {
      return structuredClone(previous);
    }

    if (previous) throw new LedgerError("AUTHOR_ALREADY_SUBMITTED");
    if (campaign.state !== "open") {
      throw new LedgerError("CAMPAIGN_NOT_OPEN");
    }
    if (this.now() >= campaign.closesAt) {
      throw new LedgerError("SUBMISSION_DEADLINE_PASSED");
    }
    if (campaign.acceptedCount >= campaign.capacity) {
      throw new LedgerError("CAMPAIGN_AT_CAPACITY");
    }

    const entry: Entry = {
      id: randomUUID(),
      campaignId: input.campaignId,
      authorId: input.authorId,
      sourceConversationId: input.sourceConversationId,
      sourceMessageId: input.sourceMessageId,
      title: input.title,
      url: input.url,
      state: "queued",
      createdAt: this.now(),
    };

    campaign.acceptedCount += 1;
    this.entries.set(entry.id, entry);
    return structuredClone(entry);
  }

  claimNext(
    campaignId: string,
    reviewerId: string,
    leaseDurationMs: number,
  ): Entry | undefined {
    if (leaseDurationMs < 1) throw new LedgerError("INVALID_LEASE");

    this.requireCampaign(campaignId);
    this.reapExpiredLeases(campaignId);

    const candidate = [...this.entries.values()]
      .filter(
        entry =>
          entry.campaignId === campaignId && entry.state === "queued",
      )
      .sort((a, b) => a.createdAt - b.createdAt)[0];

    if (!candidate) return undefined;

    candidate.state = "leased";
    candidate.lease = {
      token: randomUUID(),
      reviewerId,
      expiresAt: this.now() + leaseDurationMs,
    };

    return structuredClone(candidate);
  }

  complete(entryId: string, leaseToken: string, summary: string): Entry {
    const entry = this.requireEntry(entryId);

    if (entry.state !== "leased" || !entry.lease) {
      throw new LedgerError("ENTRY_NOT_LEASED");
    }
    if (entry.lease.token !== leaseToken) {
      throw new LedgerError("LEASE_TOKEN_MISMATCH");
    }
    if (this.now() >= entry.lease.expiresAt) {
      entry.state = "queued";
      delete entry.lease;
      throw new LedgerError("LEASE_EXPIRED");
    }
    if (!summary.trim()) throw new LedgerError("EMPTY_REVIEW");

    entry.state = "done";
    entry.reviewSummary = summary.trim();
    entry.completedBy = entry.lease.reviewerId;
    delete entry.lease;

    return structuredClone(entry);
  }

  withdraw(entryId: string, authorId: string): Entry {
    const entry = this.requireEntry(entryId);

    if (entry.authorId !== authorId) {
      throw new LedgerError("NOT_ENTRY_AUTHOR");
    }
    if (entry.state === "done") {
      throw new LedgerError("ENTRY_ALREADY_COMPLETED");
    }

    entry.state = "withdrawn";
    delete entry.lease;
    return structuredClone(entry);
  }

  beginDraining(campaignId: string): Campaign {
    const campaign = this.requireCampaign(campaignId);
    if (campaign.state !== "open") {
      throw new LedgerError("CAMPAIGN_NOT_OPEN");
    }

    campaign.state = "draining";
    return structuredClone(campaign);
  }

  close(campaignId: string): Campaign {
    const campaign = this.requireCampaign(campaignId);
    if (campaign.state !== "draining") {
      throw new LedgerError("CAMPAIGN_NOT_DRAINING");
    }

    this.reapExpiredLeases(campaignId);

    const unfinished = [...this.entries.values()].some(
      entry =>
        entry.campaignId === campaignId &&
        (entry.state === "queued" || entry.state === "leased"),
    );

    if (unfinished) throw new LedgerError("UNFINISHED_ENTRIES");

    campaign.state = "closed";
    return structuredClone(campaign);
  }

  progress(campaignId: string) {
    const relevant = [...this.entries.values()].filter(
      entry => entry.campaignId === campaignId,
    );

    return {
      accepted: relevant.length,
      queued: relevant.filter(entry => entry.state === "queued").length,
      leased: relevant.filter(entry => entry.state === "leased").length,
      done: relevant.filter(entry => entry.state === "done").length,
      withdrawn: relevant.filter(entry => entry.state === "withdrawn").length,
    };
  }

  private reapExpiredLeases(campaignId: string): void {
    for (const entry of this.entries.values()) {
      if (
        entry.campaignId === campaignId &&
        entry.state === "leased" &&
        entry.lease &&
        this.now() >= entry.lease.expiresAt
      ) {
        entry.state = "queued";
        delete entry.lease;
      }
    }
  }

  private requireCampaign(id: string): Campaign {
    const campaign = this.campaigns.get(id);
    if (!campaign) throw new LedgerError("CAMPAIGN_NOT_FOUND");
    return campaign;
  }

  private requireEntry(id: string): Entry {
    const entry = this.entries.get(id);
    if (!entry) throw new LedgerError("ENTRY_NOT_FOUND");
    return entry;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally an in-memory core. It makes the policy easy to reproduce before database behavior, SDK callbacks, or UI state can obscure it.

Make the ownership race fail in a test

Create src/ledger.test.ts:

import assert from "node:assert/strict";
import { CommitmentLedger, LedgerError } from "./ledger.js";

let clock = 1_000;
const ledger = new CommitmentLedger(() => clock);

ledger.createCampaign({
  id: "showcase-2026",
  capacity: 2,
  closesAt: 10_000,
});

const alice = ledger.submit({
  campaignId: "showcase-2026",
  authorId: "alice",
  sourceConversationId: "community-main",
  sourceMessageId: "message-101",
  title: "Alice's selected project",
  url: "https://example.test/alice",
});

// Redelivery of the same source message is idempotent.
const aliceRetry = ledger.submit({
  campaignId: "showcase-2026",
  authorId: "alice",
  sourceConversationId: "community-main",
  sourceMessageId: "message-101",
  title: "Alice's selected project",
  url: "https://example.test/alice",
});
assert.equal(aliceRetry.id, alice.id);

const bob = ledger.submit({
  campaignId: "showcase-2026",
  authorId: "bob",
  sourceConversationId: "community-main",
  sourceMessageId: "message-102",
  title: "Bob's selected project",
  url: "https://example.test/bob",
});

assert.throws(
  () =>
    ledger.submit({
      campaignId: "showcase-2026",
      authorId: "carol",
      sourceConversationId: "community-main",
      sourceMessageId: "message-103",
      title: "Carol's project",
      url: "https://example.test/carol",
    }),
  (error: unknown) =>
    error instanceof LedgerError && error.code === "CAMPAIGN_AT_CAPACITY",
);

const firstLease = ledger.claimNext("showcase-2026", "reviewer-a", 500)!;
assert.equal(firstLease.id, alice.id);

// Reviewer A disappears. The lease expires and Reviewer B receives the item.
clock += 501;
const secondLease = ledger.claimNext("showcase-2026", "reviewer-b", 500)!;
assert.equal(secondLease.id, alice.id);

// The late completion from Reviewer A must not overwrite Reviewer B's claim.
assert.throws(
  () => ledger.complete(alice.id, firstLease.lease!.token, "Late result"),
  (error: unknown) =>
    error instanceof LedgerError && error.code === "LEASE_TOKEN_MISMATCH",
);

ledger.complete(
  alice.id,
  secondLease.lease!.token,
  "Reviewed with an actionable response.",
);

const bobLease = ledger.claimNext("showcase-2026", "reviewer-c", 500)!;
ledger.withdraw(bob.id, "bob");

// Withdrawal invalidates an outstanding reviewer lease.
assert.throws(
  () => ledger.complete(bob.id, bobLease.lease!.token, "Should not commit"),
  (error: unknown) =>
    error instanceof LedgerError && error.code === "ENTRY_NOT_LEASED",
);

ledger.beginDraining("showcase-2026");
ledger.close("showcase-2026");

assert.deepEqual(ledger.progress("showcase-2026"), {
  accepted: 2,
  queued: 0,
  leased: 0,
  done: 1,
  withdrawn: 1,
});

console.log("All commitment-ledger checks passed.");
Enter fullscreen mode Exit fullscreen mode

Run it:

npm test
Enter fullscreen mode Exit fullscreen mode

You should see:

All commitment-ledger checks passed.
Enter fullscreen mode Exit fullscreen mode

The critical assertion is not the happy-path completion. It is the rejection of Reviewer A's late result after the lease has been reassigned.

Connect it to a Tencent RTC community without inventing a second inbox

Use the Tencent RTC community or group discussion as the interaction surface. Keep the workflow integration behind an application-owned adapter instead of coupling policy to a particular UI callback:

export interface CommunitySurface {
  showCampaignCard(input: {
    conversationId: string;
    campaignId: string;
    state: "open" | "draining" | "closed";
    accepted: number;
    capacity: number;
  }): Promise<void>;

  acknowledgeSubmission(input: {
    conversationId: string;
    sourceMessageId: string;
    entryId: string;
    positionLabel: string;
  }): Promise<void>;

  announceClosure(input: {
    conversationId: string;
    completed: number;
    withdrawn: number;
  }): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Map this adapter to the documented messaging integration used by your application. The ledger itself should not guess SDK method names or treat a rendered chat message as a successful database transition.

A practical request path is:

  1. A member uses a “Nominate this work” action in the existing community conversation.
  2. Your server authenticates the member and validates the submitted fields.
  3. The ledger transaction accepts or rejects the nomination.
  4. The response immediately renders the entry ID and current status to that member.
  5. A community acknowledgement is posted as a separate delivery attempt.
  6. The campaign card is refreshed from progress(), not from counted chat replies.

Step 4 prevents a notification failure from erasing the user's receipt. If step 5 fails, store the notification as pending and retry it separately. Do not run submit() again merely because acknowledgement delivery failed.

That gives you two independent facts:

Commitment fact: entry 8f... was accepted
Delivery fact: acknowledgement for entry 8f... is pending
Enter fullscreen mode Exit fullscreen mode

Conflating those facts is how duplicate submissions and misleading “please retry” messages appear.

Production persistence needs four atomic guarantees

The in-memory implementation is deterministic but not safe across multiple server processes. Preserve these invariants when moving it to a database:

  1. One author per campaign: enforce a unique constraint on (campaign_id, author_id).
  2. Capacity admission: increment the accepted count and insert the entry in one transaction. If either fails, roll back both.
  3. Exclusive claiming: claim only a queued entry—or atomically reclaim an expired lease—and store a new opaque token.
  4. Conditional completion: update to done only when the entry is still leased with the supplied token and an unexpired lease.

Do not implement capacity as “count rows, then insert.” Two concurrent requests can both observe the final free slot. The capacity check must participate in the same transaction as admission.

Keep an append-only audit record for moderation and dispute handling:

type AuditEvent = {
  eventId: string;
  campaignId: string;
  entryId?: string;
  actorId: string;
  action:
    | "entry.accepted"
    | "entry.claimed"
    | "lease.expired"
    | "entry.completed"
    | "entry.withdrawn"
    | "campaign.draining"
    | "campaign.closed";
  occurredAt: number;
};
Enter fullscreen mode Exit fullscreen mode

The audit log explains transitions; it does not replace the current-state table used for conditional writes.

Put translation around the workflow, not inside its authority rules

A multilingual community may need campaign instructions and reviewer responses translated. Tencent RTC documents on-demand text-message translation for TUIChat, together with applicable content types, supported languages, and edition limitations: TUIChat message translation.

Check those documented constraints for your deployment before presenting translation as available.

Translation should remain a display operation:

  • preserve the original campaign deadline and capacity values;
  • calculate state transitions from structured fields, not translated prose;
  • keep the original reviewer response accessible;
  • label translated text as translated;
  • let a translation failure leave the original message and ledger status usable.

For example, never parse “two places remaining” from either the original or translated message. Render that sentence from { capacity, acceptedCount } every time.

Failure behavior users can understand

A reliable workflow tells people what happened without asking them to infer it from silence.

Failure Ledger behavior User-visible response
Submission is delivered twice Return the existing entry for the same source message “Already accepted” with the original receipt
A different second nomination is submitted Reject it Link to the existing nomination and its status
Capacity is exhausted Do not create an entry “Intake is full; this work was not accepted”
Deadline passes Do not create an entry Show the recorded deadline and closed intake state
Reviewer closes the browser Lease eventually expires Return the entry to the review queue
Original reviewer returns late Reject the stale token Tell the reviewer the item was reassigned
Author withdraws during review Invalidate the lease Remove it from active reviewer work
Acknowledgement message fails Keep the accepted entry Show the receipt locally and retry notification
Translation fails Preserve workflow state Show the original text and a retry control
Moderator removes unsafe content Apply the moderation policy and record the action Show a neutral unavailable or removed state

External links and rich media should still pass your community's moderation and safety policy. Avoid fetching and publishing arbitrary previews before validation. Also decide whether review summaries are public, author-only, or moderator-only; that visibility choice should not be inferred from the entry state.

Release verification checklist

Before opening the real campaign, verify the whole procedure rather than only the reducer:

  • [ ] Two simultaneous requests cannot consume the same final capacity slot.
  • [ ] Redelivery of one source message returns the same entry ID.
  • [ ] A second, different submission from the same author is rejected clearly.
  • [ ] Intake stops at both the configured deadline and manual draining state.
  • [ ] An expired lease can be reassigned.
  • [ ] A stale lease token cannot complete reassigned work.
  • [ ] Author withdrawal invalidates an active lease.
  • [ ] The campaign cannot close with queued or leased entries.
  • [ ] A failed chat acknowledgement does not change admission state.
  • [ ] Progress is derived from ledger records, not message counts.
  • [ ] Original text remains available when translation fails.
  • [ ] Moderators can trace who accepted, claimed, completed, or withdrew an entry.
  • [ ] The final community update distinguishes completed, withdrawn, and unaccepted work.

The useful reframing

When participation exceeds expectations, the answer is not automatically more automation or another channel. First name what changed: a community conversation accumulated obligations.

Once that happens, reliability comes from making four things explicit:

  1. what the community is still accepting;
  2. which submissions were actually accepted;
  3. who currently owns each accepted action;
  4. what must be true before the promise can be called complete.

The chat remains where the community gathers. The ledger makes sure enthusiasm does not turn into invisible, unowned work.

Discussion: If your community makes a review, showcase, or mentorship promise, what counts as completion—and can contributors see that state without asking a moderator?


Disclosure: I wrote this article in collaboration with Tencent RTC, and used official Tencent RTC documentation as the implementation reference.

Top comments (0)