Listings::Collection
Purpose
A Collection is a named, hand-picked list of public job listings with its own shareable page at /collections/{slug}. It exists because marketing needs to promote a specific set of jobs through channels where the link cannot be edited after it is sent — push notifications, in-app banners, WhatsApp blasts. A pushed link is frozen, so the URL must name a record we control (the collection) instead of encoding the current answer to a query. Marketing edits the collection; everyone who already received the link sees the updated list.
The first collection was built for a real request: Alvin (marketing) needed a page showing only the 10 July McDonald's shifts that still had 0 applicants, pushed to workers before the promotion date. The company landing page was too broad — it mixes other outlets, other dates, and shifts that already have applicants.
Two alternatives were considered and rejected:
- Job IDs in the query string (
/jobs?ids=8231,8232,...) — the list is frozen inside every link already sent, the URL is long and fragile, and internal IDs are exposed publicly. - Nesting under the company page (
/companies/mcdonalds/urgent-jobs) — the company page is an evergreen search-engine asset while a campaign page is disposable, and nesting locks the feature to one company. A collection can span companies ("urgent shifts this weekend").
Membership is manual in this design: a person picks the exact jobs. A rule-based ("smart") collection — for example "all McDonald's shifts on 10 July with 0 applicants", evaluated automatically — is a known future direction, not part of this model today. It is blocked on two data gaps: job listings cannot be filtered by shift date, and applicant counts are not available in this system.
Model Context
| Context | Details |
|---|---|
| Aggregate | Listings::Collection + Listings::CollectionItem |
| Layer | Marketplace read surface (merchandising — deciding which jobs get a promoted page) |
| Upstream dependencies | Listings::Job rows must already exist for every member |
| Downstream dependents | None today. The public collection page reads it; a future rule-based membership source would extend it |
State Machine
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | active | UC-1 | A collection is publicly resolvable from the moment it exists |
active | archived | UC-4 | End of life. The slug stays reserved and is never reused |
Note the difference between three ways a collection stops showing jobs:
- All members closed — the page shows the ended state. The collection is still
active; if a member reopens, it reappears. ends_atpassed — the page shows the ended state permanently, but the record staysactivefor reporting.archived— the collection is not publicly resolvable at all. Visitors following an old link are sent to the main job list.
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Create a collection for a marketing campaign | Marketing prepares a push that needs a shareable page | Admin (ops) |
| UC-2 | Edit a collection's details and membership while the campaign runs | Marketing adds, removes, or reorders promoted jobs | Admin (ops) |
| UC-3 | View a collection page with its open member jobs | A person opens a pushed or shared collection link | Talent |
| UC-4 | Archive a collection when the campaign is over | The promotion has ended and the page should retire | Admin (ops) |
UC-1: Create a collection for a marketing campaign
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops, acting on a marketing request) |
| Trigger | Marketing prepares a push campaign that needs a shareable page |
Preconditions:
- Every job to include already exists as a
Listings::Job. - The chosen slug is not used by any collection, including archived ones.
System Behaviour:
- The system creates the collection with a slug, a title, an optional subtitle, and optional
starts_at/ends_atwindow. - The system creates one
Listings::CollectionItemper member, recording the display order marketing chose. - The collection starts as
activeandindexable = false.
Business Rules:
- The slug is chosen once. It never changes after creation, because the link may already be distributed.
indexablestarts asfalse. Campaign pages must stay out of search engines — they expire and would become dead search results. Only stable, evergreen collections are flipped totrue, deliberately.- Marketing supplies jobs as job page URLs, not internal IDs — each URL resolves to exactly one
Listings::Job.
Postconditions:
- The collection page is reachable at
/collections/{slug}and shows the open members in order.
Open Questions:
- Should a collection have a maximum member count? Hand-picked lists are naturally small, but nothing enforces it.
UC-2: Edit a collection's details and membership while the campaign runs
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops, acting on a marketing request) |
| Trigger | Marketing adds, removes, or reorders promoted jobs |
Preconditions:
- The collection is
active.
System Behaviour:
- The system adds or removes
Listings::CollectionItemrows, or updates their positions. - The system may update
title,subtitle,starts_at,ends_at, andindexable. - Every visitor who opens the already-distributed link sees the updated list immediately.
Business Rules:
- The slug cannot be edited. This is the core promise of the model: the link keeps working while the contents change.
- A job can be a member of many collections, but at most once per collection.
Postconditions:
- The public page reflects the new membership and order on the next view.
UC-3: View a collection page with its open member jobs
| Field | Details |
|---|---|
| Actor | Talent::Profile (job seeker or gig worker; also anonymous visitors — no login is required) |
| Trigger | A person opens a pushed or shared collection link |
Preconditions:
- The collection is
active.
System Behaviour:
- The system resolves the slug and loads the members whose
Listings::Jobstatus isopen, in marketing's order. - The page renders the collection title, subtitle, and the standard job cards; each card opens the normal job detail page.
- If
indexableisfalse, the page tells search engines not to index it. - If there are no open members, or
ends_athas passed, the page shows an ended message ("All shifts in this promotion have been taken.") with a link to the main job list. - If the slug does not resolve (unknown, or the collection is
archived), the visitor is sent to the main job list instead of an error page.
Business Rules:
- Only
openmembers are shown. A filled or removed job disappears from the page without any change to the collection itself — the membership row stays, the listing's own status hides it. - Order is marketing's
position, never recency. - A previously shared link must never land on an error page. Links live in notification trays for weeks.
Postconditions:
- Read-only operation — no data changes.
UC-4: Archive a collection when the campaign is over
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | The promotion has ended and the page should retire |
Preconditions:
- The collection is
active.
System Behaviour:
- The system sets the status to
archived. - The public page stops resolving; visitors following the old link are sent to the main job list.
Business Rules:
- Archival is the only way a collection ends. Collections are never hard-deleted, and the slug is never released for reuse — a dead link must not come back to life showing different content.
Postconditions:
- The collection and its membership remain queryable internally for campaign reporting.
Open Questions:
- Is un-archiving allowed if a collection is archived by mistake, or is the correction path "create a new collection with a new slug"?
Invariants
- A slug is unique across all collections — including
archivedones — and never changes after creation. - A
Listings::Jobappears at most once per collection. - Only members whose
Listings::Jobstatus isopenare publicly visible. Closed members are hidden by the listing's own status, not removed from the collection. - A collection with
indexable = falsealways instructs search engines not to index its page. - A collection past its
ends_atshows the ended state, regardless of how many members are still open. - A previously shared collection link never renders an error page: it shows the collection, the ended state, or forwards to the main job list.
- Collections end by archival only — never hard deletion.
- Public display order is
position, controlled by the editor — never creation time or recency.
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Listings::CollectionItem | has_many (aggregate child) | Holds one membership with its display position; created and removed through the collection |
Listings::Job | has_many, through: collection_items | Membership points at the live listing — no snapshot. Title, pay, and status changes on the listing appear on the collection page immediately |
Gig::TempJob / Careers::Job | indirect, via Listings::Job | The source systems flip a listing to closed when a shift fills or a posting ends; that alone prunes the collection page — the collection never tracks applicant counts itself |