Ideation & Challenges Module¶
Audience: maintainers and contributors working on community idea management, challenge lifecycles, voting, outcomes, or campaign organisation.
What it does¶
The Ideation & Challenges module lets tenants run structured idea-collection campaigns:
- Admins create challenges — community problem statements with a lifecycle, deadlines, optional categories, tags, and a prize description.
- Members submit ideas in response to a challenge, vote on each other's ideas, comment, and save drafts.
- Admins progress challenges through a controlled lifecycle, shortlist and select winning ideas, and record outcomes (implementation status and impact).
- Campaigns group related challenges into a themed collection.
- Templates allow admins to pre-configure recurring challenge patterns.
- Winning ideas can be converted into groups (implementation teams) with a dedicated chatroom, task board, and document store.
Not to be confused with Gamification Challenges. The
challengestable andChallengeServicepower time-bound XP/badge rewards on the gamification track (GET /v2/gamification/challenges). Ideation challenges use theideation_challengestable andIdeationChallengeService. The two systems are fully independent — they share only the word "challenge".
Feature gate¶
Feature flag: ideation_challenges (default: ON).
- PHP:
TenantContext::hasFeature('ideation_challenges')— checked inIdeationChallengesController::ensureFeature()before every action. Returns HTTP 403 when the feature is disabled. - React: all ideation routes are wrapped in
<FeatureGate feature="ideation_challenges">. When disabled, the browse page shows a coming-soon placeholder; deeper routes redirect to/. - Accessible (GOV.UK) frontend: gated in
AlphaControllerusing the sameTenantContext::hasFeature()check (seeapp/Http/Controllers/GovukAlpha/Concerns/IdeationParity.php). - Default value declared in
app/Services/TenantFeatureConfig.php.
Challenge lifecycle¶
Status transitions are validated server-side. Only admins can advance status.
draft → open → voting → evaluating → closed → archived
└──────── evaluating → closed ──────────────┘
closed → open (re-open)
Allowed transitions are enforced by IdeationChallengeService::updateChallengeStatus(). Attempting an invalid transition returns HTTP 409.
Idea statuses (admin-controlled via updateIdeaStatus()): submitted → shortlisted → winner → withdrawn.
Database tables¶
All ideation tables are prefixed with challenge_ or ideation_; they carry no cross-tenant data.
| Table | Purpose |
|---|---|
ideation_challenges |
Root challenge records; holds tenant_id, lifecycle status, deadlines, tags (JSON), ideas_count, favorites_count, views_count, is_featured |
challenge_ideas |
Idea submissions; challenge_id FK; status (draft, submitted, shortlisted, winner, withdrawn); votes_count, comments_count |
challenge_idea_votes |
One row per (idea_id, user_id) pair — toggled atomically |
challenge_idea_comments |
Comments on ideas |
challenge_favorites |
User bookmarks on challenges |
challenge_categories |
Tenant-scoped taxonomy for classifying challenges; has slug, icon, color, sort_order |
challenge_tags |
Reusable tags; linked via challenge_tag_links |
challenge_tag_links |
Many-to-many join between challenge_tags and ideation_challenges |
challenge_templates |
Admin-created templates with pre-filled fields; default_tags + evaluation_criteria stored as JSON |
challenge_outcomes |
One outcome per challenge; tracks winning_idea_id, implementation status (not_started, in_progress, implemented, abandoned), and impact_description |
idea_media |
Files or URLs attached to ideas |
idea_team_links |
Links a converted idea to its implementation group |
campaigns |
Thematic collections of challenges |
campaign_challenges |
Many-to-many join with sort_order |
group_chatrooms |
Chatrooms attached to implementation groups |
group_chatroom_messages |
Messages inside chatrooms; supports pinning |
team_tasks |
Kanban tasks inside implementation groups |
team_documents |
File uploads inside implementation groups |
Tenant scoping: ideation_challenges.tenant_id is the root anchor. Ideas, votes, and comments are scoped transitively by joining through their parent challenge. Categories, tags, templates, and outcomes carry their own tenant_id via the HasTenantScope trait on the relevant Eloquent models.
Backend¶
Services¶
| Service | Responsibility |
|---|---|
app/Services/IdeationChallengeService.php |
Core CRUD for challenges, ideas, votes, comments, favorites, and draft management; sends email/push notifications to recipients in their preferred language via LocaleContext::withLocale() |
app/Services/ChallengeCategoryService.php |
Per-tenant category CRUD (admin only) |
app/Services/ChallengeTagService.php |
Tag CRUD (admin-create/delete; any authenticated user can list) |
app/Services/ChallengeTemplateService.php |
Template CRUD; getTemplateData() returns pre-filled form defaults for the challenge-create flow |
app/Services/ChallengeOutcomeService.php |
Upsert/read outcome record; getDashboard() returns aggregate stats across all closed challenges |
app/Services/CampaignService.php |
Campaign CRUD and challenge-linking |
app/Services/IdeaTeamConversionService.php |
Promotes a winning idea to an implementation group; records the link in idea_team_links |
app/Services/IdeaMediaService.php |
Media attachments on ideas |
app/Services/GroupChatroomService.php |
Chatrooms and messages inside implementation groups |
app/Services/TeamTaskService.php |
Task board for implementation groups |
app/Services/TeamDocumentService.php |
Document uploads for implementation groups |
Controllers¶
| Controller | Path prefix | Notes |
|---|---|---|
app/Http/Controllers/Api/IdeationChallengesController.php |
/api/v2/ |
All public and member-facing endpoints; also hosts group chatroom, task, and document endpoints that belong to implementation teams |
app/Http/Controllers/Api/AdminIdeationController.php |
/api/v2/admin/ideation |
Moderation view: list all challenges (offset-paginated), show, delete, advance status; requires admin middleware |
Key API endpoints¶
All endpoints require auth:sanctum unless noted. See routes/api.php for the full list.
Challenges
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /v2/ideation-challenges |
optional | Cursor-paginated; ?status=, ?category_id=, ?search=, ?cursor=, ?per_page= (1–100) |
| POST | /v2/ideation-challenges |
required | Creates challenge; fires feed-activity record; rate-limited 10 req/min |
| GET | /v2/ideation-challenges/{id} |
optional | Detail with ideas_count and is_favorited |
| PUT | /v2/ideation-challenges/{id} |
admin | Update challenge fields |
| DELETE | /v2/ideation-challenges/{id} |
admin | Hard delete |
| PUT | /v2/ideation-challenges/{id}/status |
admin | Status transition (validates allowed paths) |
| POST | /v2/ideation-challenges/{id}/favorite |
required | Toggle bookmark |
| POST | /v2/ideation-challenges/{id}/duplicate |
admin | Copies challenge as draft with [Copy] prefix |
Ideas
| Method | Path | Notes |
|---|---|---|
| GET | /v2/ideation-challenges/{id}/ideas |
?sort=votes (default) or newest; cursor-paginated |
| POST | /v2/ideation-challenges/{id}/ideas |
Submit idea; notifies challenge creator |
| GET | /v2/ideation-challenges/{id}/ideas/drafts |
User's own drafts for a challenge |
| GET | /v2/ideation-ideas/{id} |
Single idea; includes has_voted when authenticated |
| PUT | /v2/ideation-ideas/{id} |
Edit own idea while challenge is open |
| PUT | /v2/ideation-ideas/{id}/draft |
Save/publish a draft (?publish=true transitions draft → submitted) |
| DELETE | /v2/ideation-ideas/{id} |
Owner or admin; decrements counter atomically |
| POST | /v2/ideation-ideas/{id}/vote |
Toggle vote; blocked if idea is draft/withdrawn or challenge is not open/voting; users cannot vote on their own ideas |
| PUT | /v2/ideation-ideas/{id}/status |
Admin only; submitted → shortlisted → winner → withdrawn; notifies idea author |
| POST | /v2/ideation-ideas/{id}/convert-to-group |
Create implementation group from idea |
Comments
| Method | Path |
|---|---|
| GET | /v2/ideation-ideas/{id}/comments |
| POST | /v2/ideation-ideas/{id}/comments |
| DELETE | /v2/ideation-comments/{id} |
Taxonomy and templates
| Method | Path | Notes |
|---|---|---|
| GET | /v2/ideation-categories |
Public list |
| POST/PUT/DELETE | /v2/ideation-categories/{id} |
Admin only |
| GET | /v2/ideation-tags |
?type= filter |
| GET | /v2/ideation-tags/popular |
Ranked by usage count |
| POST/DELETE | /v2/ideation-tags/{id} |
Admin only |
| GET | /v2/ideation-templates |
Admin and member |
| GET | /v2/ideation-templates/{id}/data |
Pre-filled form defaults |
| POST/PUT/DELETE | /v2/ideation-templates/{id} |
Admin only |
Outcomes and campaigns
| Method | Path |
|---|---|
| GET/PUT | /v2/ideation-challenges/{id}/outcome |
| GET | /v2/ideation-outcomes/dashboard |
| GET/POST/PUT/DELETE | /v2/ideation-campaigns, /v2/ideation-campaigns/{id} |
| POST/DELETE | /v2/ideation-campaigns/{id}/challenges |
Admin moderation
| Method | Path |
|---|---|
| GET | /v2/admin/ideation — offset-paginated list; ?status=, ?search=, ?page=, ?limit= (max 200) |
| GET | /v2/admin/ideation/{id} |
| DELETE | /v2/admin/ideation/{id} |
| POST | /v2/admin/ideation/{id}/status |
Frontend entry points¶
React frontend (react-frontend/src/):
| File | Route |
|---|---|
pages/ideation/IdeationPage.tsx |
/{tenant}/ideation |
pages/ideation/ChallengeDetailPage.tsx |
/{tenant}/ideation/:id |
pages/ideation/IdeaDetailPage.tsx |
/{tenant}/ideation/:challengeId/ideas/:id |
pages/ideation/CreateChallengePage.tsx |
/{tenant}/ideation/create (authenticated) |
pages/ideation/CampaignsPage.tsx |
/{tenant}/ideation/campaigns |
pages/ideation/CampaignDetailPage.tsx |
/{tenant}/ideation/campaigns/:id |
pages/ideation/OutcomesDashboardPage.tsx |
/{tenant}/ideation/outcomes |
admin/modules/ideation/IdeationAdmin.tsx |
/admin/ideation |
components/ideation/ |
TeamChatrooms, TeamDocuments, TeamTasks — rendered inside the group/team detail view |
Accessible (GOV.UK) frontend (accessible-frontend/views/):
ideation.blade.php, ideation-idea.blade.php, ideation-detail.blade.php, ideation-challenge-form.blade.php, ideation-drafts.blade.php, ideation-manage.blade.php, ideation-outcome-form.blade.php, ideation-outcomes.blade.php, ideation-campaigns.blade.php, ideation-campaign-detail.blade.php, ideation-tags.blade.php.
Controller trait: app/Http/Controllers/GovukAlpha/Concerns/IdeationParity.php.
Lang file: lang/en/govuk_alpha_ideation.php (plus 10 locale variants).
Security and privacy invariants¶
- Every query on
ideation_challengesincludesWHERE tenant_id = ?(checked inIdeationChallengeServiceviaTenantContext::getId()). Ideas, votes, and comments are scoped transitively by joining back to the parent challenge. - Voting: users cannot vote on their own ideas; votes are rejected if the challenge is not in
openorvotingstatus; a duplicate vote toggles the existing vote off (idempotent toggle). - Challenge update and delete are admin-only. Idea edit is owner-only and blocked once the challenge leaves
openstatus. - Draft ideas are owner-private:
getUserDrafts()always filters by bothchallenge_idanduser_id. - Outcome upsert (including selecting a winning idea) is admin-only. The winning idea's membership in the challenge is validated before the FK is written.
- Duplicate operation creates a
draftwith zeroed counters; deadlines are intentionally cleared. - All notification emails are rendered inside
LocaleContext::withLocale($recipient, ...)so they go out in the recipient'spreferred_language, not the caller's locale.
Notifications¶
IdeationChallengeService fires in-app notifications and push (via NotificationDispatcher::fanOutPush) and templated HTML email (via EmailDispatchService::sendRaw) for:
| Trigger | Recipient |
|---|---|
| New idea submitted to a challenge | Challenge creator |
| Vote cast on an idea | Idea author (not on unvote) |
| Comment added to an idea | Idea author |
| Idea status changed by admin | Idea author |
Email templates use lang/en/emails_ideation.json (plus 10 locale variants).
Prerender invalidation¶
app/Observers/IdeationChallengePrerenderObserver.php — registered as an Eloquent observer on IdeationChallenge. On model save or delete it marks /ideation and /ideation/{id} for cache invalidation via PrerenderService.
Tests¶
# PHP — run from repo root
vendor/bin/phpunit --testsuite=Laravel --filter=Ideation
vendor/bin/phpunit tests/Laravel/Feature/Controllers/IdeationChallengesControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Controllers/AdminIdeationControllerTest.php
vendor/bin/phpunit tests/Laravel/Unit/Services/IdeationChallengeServiceTest.php
vendor/bin/phpunit tests/Laravel/Unit/Services/ChallengeOutcomeServiceTest.php
vendor/bin/phpunit tests/Laravel/Feature/GovukAlpha/IdeationParityTest.php
# React — run from react-frontend/
npm test -- --testPathPattern=ideation
Key coverage:
IdeationChallengesControllerTest— HTTP-level feature tests for challenge CRUD, idea submission, voting, and auth guards usingDatabaseTransactions.IdeationChallengeServiceTest— unit tests with mocked DB; covers pagination shape, vote toggle, and draft publish path.ChallengeOutcomeServiceTest— outcome upsert and dashboard aggregation.IdeationParityTest— GOV.UK accessible-frontend route integration tests.- React page/component tests:
IdeationPage.test.tsx,ChallengeDetailPage.test.tsx,IdeaDetailPage.test.tsx,CreateChallengePage.test.tsx,IdeationAdmin.test.tsx, and the team sub-components.
Failure modes and recovery¶
| Failure | Behaviour | Recovery |
|---|---|---|
| Feature disabled for tenant | All API endpoints return HTTP 403 (FEATURE_DISABLED); React routes show a coming-soon page |
Enable ideation_challenges in tenant settings via the admin panel |
| Invalid status transition | updateChallengeStatus() returns CONFLICT error; HTTP 409 |
Advance through the correct sequence (e.g. open → voting before voting → evaluating) |
| Vote on closed/evaluating challenge | voteIdea() returns CONFLICT (challenge_voting_not_allowed) |
No action needed; the challenge must be open or voting to accept votes |
Idea edit after challenge leaves open |
updateIdea() returns CONFLICT (challenge_closed_for_edits) |
Reopen the challenge status (admin) or use the admin idea-status endpoint to set the idea withdrawn |
| Notification email failure | EmailDispatchService::sendRaw() returns false; logged as Log::warning — the primary action still succeeds |
Check email_logs table or SendGrid Activity for delivery status |
Outcome winning_idea_id FK violation |
Service validates that the idea belongs to the challenge; returns VALIDATION_INVALID_VALUE |
Pass a valid idea ID that was submitted to the same challenge |
| Team conversion on non-shortlisted idea | IdeaTeamConversionService checks idea eligibility; returns FORBIDDEN or CONFLICT |
Shortlist or mark the idea as winner before converting |
| Category deleted while challenges reference it | ChallengeCategoryService::delete() nulls out category_id on affected challenges before deleting the category row |
No recovery needed; challenges retain their free-text category field |