# How to Add Crowdsourced Event Planning to Your App — Copy-Paste Prompts Included

- Tool: App Features
- Last updated: July 2026

## TL;DR

Crowdsourced event planning lets community members propose events, upvote them to the top, vote on the best date, and comment — with admins approving the winners into your events calendar. You need Supabase for votes, proposals, and date polls; Supabase Realtime for live vote count updates; and a Resend Edge Function for approval emails. With Lovable or V0 you can ship in 4–8 hours. Running costs start at $0/month on Supabase's free tier and reach $50–75/month at 10,000 users.

## What Crowdsourced Event Planning Actually Is

Crowdsourced event planning turns your community into co-creators: members propose event ideas, vote on which ones should happen, pick the best date from a Doodle-style poll, and comment on the details — while admins retain final approval before the event appears on the public calendar. The core product decisions are how to surface the best proposals (a time-decay hot score beats pure vote count), how to prevent vote manipulation (database unique constraints, not client-side logic), and how tightly to couple the admin approval workflow to your existing events system. The result is higher attendance and lower organizer burnout, because the community self-selects events people actually want.

## Anatomy of the Feature

Six components. The voting and hot-score systems are where builds fail — AI tools default to client-side logic that is both slow and manipulatable.

- **Event proposal feed** (ui): shadcn/ui Card list with infinite scroll powered by Tanstack Query's useInfiniteQuery. Each card shows the proposal title, proposer avatar and display name, vote score, comment count, date poll status, and a status badge. The feed is sorted by a hot score computed in Postgres — never in the React component.
- **Voting system** (backend): Supabase votes table with columns (user_id, proposal_id, value CHECK IN (-1, 1)) and a UNIQUE constraint on (user_id, proposal_id). Vote total is computed via a Postgres generated column or a materialized view refreshed on each vote INSERT/DELETE. Supabase Realtime broadcasts the updated vote_score to all subscribers on the proposals channel.
- **Date polling system** (data): Proposer submits 3–5 date options stored in date_poll_options. Attendees respond per option (is_available bool) in date_poll_responses with a UNIQUE constraint on (user_id, option_id). The winning date is the option with the highest count of is_available = true responses. Supabase Realtime subscription pushes response count updates live.
- **Comment threads** (backend): Supabase comments table with a self-referential parent_id FK for 2-level nesting. Fetch parent comments and their immediate children in two queries — PostgREST does not support recursive CTEs from the JS client, so do not attempt app-level recursion. react-markdown renders safe markdown in comment content. Supabase Realtime pushes new comments to the open proposal.
- **Admin moderation system** (backend): Admin role stored as a Supabase custom JWT claim in app_metadata.role = 'admin'. RLS policies check auth.jwt() -> 'app_metadata' ->> 'role' = 'admin' for UPDATE on proposal status. Admin panel shows all proposals with filter tabs (Proposed, Voting, Approved, Rejected). Approval triggers a Supabase Edge Function that creates an entry in the events table and sends a Resend email to everyone who voted.
- **Notification system** (backend): Supabase Edge Function triggered by admin approval status change sends Resend emails to all voters on that proposal, including the proposer. Additional Edge Function sends email when a comment is posted on a proposal you created or voted on. Emails contain direct links back to the proposal page.

## Data model

Run this in the Supabase SQL editor. The hot score as a generated column means sorting is always fast and cannot be manipulated client-side. The UNIQUE constraints prevent double-voting at the database level.

```sql
create table public.event_proposals (
  id uuid primary key default gen_random_uuid(),
  created_by uuid references auth.users(id) on delete cascade not null,
  title text not null,
  description text,
  location text,
  category text,
  status text not null default 'proposed' check (status in ('proposed','voting','approved','rejected')),
  vote_score int not null default 0,
  hot_score numeric generated always as (
    vote_score::numeric / power(
      (extract(epoch from (now() - created_at)) / 3600.0) + 2,
      1.5
    )
  ) stored,
  created_at timestamptz not null default now()
);

create table public.votes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  proposal_id uuid references public.event_proposals(id) on delete cascade not null,
  value int not null check (value in (-1, 1)),
  created_at timestamptz not null default now(),
  unique(user_id, proposal_id)
);

create table public.date_poll_options (
  id uuid primary key default gen_random_uuid(),
  proposal_id uuid references public.event_proposals(id) on delete cascade not null,
  date_option date not null,
  created_at timestamptz not null default now()
);

create table public.date_poll_responses (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  option_id uuid references public.date_poll_options(id) on delete cascade not null,
  is_available bool not null,
  created_at timestamptz not null default now(),
  unique(user_id, option_id)
);

create table public.comments (
  id uuid primary key default gen_random_uuid(),
  proposal_id uuid references public.event_proposals(id) on delete cascade not null,
  user_id uuid references auth.users(id) on delete cascade not null,
  parent_id uuid references public.comments(id) on delete cascade,
  content text not null,
  created_at timestamptz not null default now()
);

-- RLS
alter table public.event_proposals enable row level security;
alter table public.votes enable row level security;
alter table public.date_poll_options enable row level security;
alter table public.date_poll_responses enable row level security;
alter table public.comments enable row level security;

-- Proposals: anyone authenticated can read; authenticated users can insert own; admins can update status
create policy "Authenticated users can read proposals"
  on public.event_proposals for select
  using (auth.uid() is not null);

create policy "Authenticated users can propose"
  on public.event_proposals for insert
  with check (auth.uid() = created_by);

create policy "Admins can update proposal status"
  on public.event_proposals for update
  using (
    (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
    or auth.uid() = created_by
  );

-- Votes: authenticated users can vote; unique constraint prevents double-vote
create policy "Authenticated users can vote"
  on public.votes for insert
  with check (auth.uid() = user_id and auth.uid() is not null);

create policy "Users can remove own votes"
  on public.votes for delete
  using (auth.uid() = user_id);

create policy "Votes are readable by authenticated users"
  on public.votes for select
  using (auth.uid() is not null);

-- Date poll options and responses: readable by all authenticated
create policy "Authenticated users can read poll options"
  on public.date_poll_options for select
  using (auth.uid() is not null);

create policy "Proposal owner can add date options"
  on public.date_poll_options for insert
  with check (
    auth.uid() = (select created_by from public.event_proposals where id = proposal_id)
  );

create policy "Authenticated users can respond to polls"
  on public.date_poll_responses for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- Comments: readable by authenticated; writeable by authenticated
create policy "Authenticated users can read comments"
  on public.comments for select
  using (auth.uid() is not null);

create policy "Authenticated users can comment"
  on public.comments for insert
  with check (auth.uid() = user_id);

-- Trigger to update vote_score on votes insert/delete
create or replace function public.update_proposal_vote_score()
returns trigger language plpgsql security definer as $$
begin
  if tg_op = 'INSERT' then
    update public.event_proposals
    set vote_score = vote_score + new.value
    where id = new.proposal_id;
  elsif tg_op = 'DELETE' then
    update public.event_proposals
    set vote_score = vote_score - old.value
    where id = old.proposal_id;
  end if;
  return null;
end;
$$;

create trigger votes_update_score
  after insert or delete on public.votes
  for each row execute function public.update_proposal_vote_score();

-- Indexes
create index proposals_hot_score_idx on public.event_proposals (hot_score desc);
create index proposals_status_idx on public.event_proposals (status);
create index comments_proposal_parent_idx on public.comments (proposal_id, parent_id);
```

The hot_score generated column and its index mean ORDER BY hot_score DESC on the feed query never triggers a sort — it uses the index. The trigger keeps vote_score in sync atomically with every vote insert or delete.

## Build paths

### Lovable — fit 4/10, 4–7 hours

Lovable handles Supabase Realtime voting feed and nested comments well; infinite scroll and the hot-score sorting algorithm need precise specification in the prompt to generate correctly.

1. Create a new Lovable project, connect Lovable Cloud to provision Supabase, and confirm Auth is enabled in the Cloud tab
2. Paste the prompt below and let Agent Mode build the proposal feed, proposal creation form, voting buttons, date poll UI, comment threads, and admin moderation panel as separate pages or components
3. After generation, open the Cloud tab → Database and run the vote_score trigger SQL manually if Lovable did not generate it — the trigger is easy to miss
4. Test voting with two different browser sessions to verify the unique constraint blocks double votes
5. Set up admin JWT claims in the Supabase Dashboard under Authentication → Users → select user → Edit → App Metadata: add { "role": "admin" }

Starter prompt:

```
Build a crowdsourced event planning feature. Community members propose events and vote on them. Database tables in Supabase: event_proposals (id, created_by FK auth.users, title, description, location, category, status CHECK IN ('proposed','voting','approved','rejected'), vote_score int default 0, created_at); votes (user_id FK, proposal_id FK, value CHECK IN (-1, 1), UNIQUE(user_id, proposal_id)); date_poll_options (id, proposal_id FK, date_option date); date_poll_responses (user_id FK, option_id FK, is_available bool, UNIQUE(user_id, option_id)); comments (id, proposal_id FK, user_id FK, parent_id FK self-ref, content, created_at). Proposal feed: shadcn/ui Card list sorted by hot_score (votes / (age_hours + 2)^1.5) — compute in Postgres, never React; infinite scroll with Tanstack Query useInfiniteQuery; show vote count, comment count, status badge. Voting: upvote/downvote buttons; enforce one vote per user with UNIQUE DB constraint; optimistic UI update; cancel vote by clicking same button again (DELETE from votes). Date poll: proposer can add 3-5 date options; voters check which dates they're available; show which date has most availability. Comments: nested 2 levels (parent + reply); react-markdown for content; Supabase Realtime for live updates. Admin panel (role check via Supabase JWT claim app_metadata.role = 'admin'): approve/reject/pin proposals; on approval create entry in Supabase events table (if it exists) and send Resend email to all voters. Handle edge cases: user voting on own proposal (allow but note it); proposal with no date options still valid; tie in date poll shows multiple winners; empty state when no proposals; 'load more' vs infinite scroll. Supabase Realtime: subscribe to single proposals channel for vote count updates — do not subscribe per proposal row.
```

Limitations:

- Complex nested comment rendering with Realtime updates often degrades across multiple Lovable correction prompts — specify 2-level nesting and two separate queries explicitly
- The hot-score formula (time-decay sort) must be spelled out exactly in the prompt; Lovable defaults to ORDER BY vote_score which lets old proposals dominate
- Lovable Cloud's managed Supabase does not expose the SQL editor in the Supabase Dashboard — use the Cloud tab SQL runner for manual schema adjustments

### V0 — fit 4/10, 5–8 hours

V0 generates a clean Next.js feed with shadcn/ui Cards and Server Components for ISR-cached proposal lists; Client Components handle voting with optimistic updates and Supabase Realtime subscriptions.

1. Prompt V0 with the spec below to generate the proposal feed, voting components, date poll, comment threads, and admin panel pages
2. Add NEXT_PUBLIC_SUPABASE_URL and SUPABASE_ANON_KEY in the V0 Vars panel; add RESEND_API_KEY for approval email notification
3. Run the SQL schema from this page in the Supabase SQL editor — include the vote_score trigger and hot_score generated column
4. Set up Supabase JWT claims for the admin user and verify the RLS policy on event_proposals UPDATE works by testing status change in two browser sessions
5. Deploy via V0 Publish and test infinite scroll, Realtime voting, and date poll voting with two simultaneous users

Starter prompt:

```
Build a crowdsourced event planning feature in Next.js (App Router) with Supabase and shadcn/ui. Architecture: /proposals page — Server Component fetching first page of proposals via Supabase, sorted by hot_score DESC (computed Postgres column), wrapped in a Client Component for infinite scroll with Tanstack Query useInfiniteQuery; each proposal card shows title, proposer name, vote score, comment count, status badge. ProposalCard Client Component: upvote/downvote buttons that call Supabase RPC or insert/delete from votes table; optimistic UI update with rollback on error; Supabase Realtime subscription on the 'proposals' channel (one channel for all proposals, not per-row) to update vote counts live. /proposals/new — form to submit a new event proposal with title, description, location, category, and 1-5 date option pickers. /proposals/:id — proposal detail page with date poll (checkboxes per date option, show availability count per option), nested comment thread (2 levels), reply button on root comments; Supabase Realtime for live comment updates. /admin/proposals — admin page (check auth.jwt() app_metadata.role via server-side Supabase client); table of all proposals with status filter tabs; approve button triggers Server Action that updates status, creates events table entry, and calls Resend API to notify voters. Handle edge cases: no proposals (empty state with 'Be the first to propose' CTA); proposal date options where all voters chose the same date (show as clear winner); user not authenticated (show read-only feed, prompt to sign in to vote or comment).
```

Limitations:

- V0 may generate client-side vote sorting (sort by vote_score in React) instead of using the Postgres hot_score column — specify ORDER BY hot_score DESC from Supabase explicitly
- Infinite scroll with Tanstack Query needs explicit specification; V0 defaults to a simple list with no pagination
- V0 sometimes uses localStorage for voting state instead of Supabase — always specify that vote state lives in the votes table

### Custom — fit 3/10, 1–2 weeks

Custom development is only justified when the crowdsourcing feature integrates with ticketing systems, venue booking, budget crowdfunding, or external event platforms like Eventbrite or Lu.ma.

1. Build a dedicated Next.js API layer for proposal scoring — a pg_cron job refreshes the hot_score materialized view every 5 minutes for high-volume communities
2. Integrate Eventbrite or Lu.ma webhooks to sync approved proposals directly to external event platforms without manual data re-entry
3. Add Stripe crowdfunding module — each proposal has a funding goal; proposals only reach 'voting' status after minimum funding commitment is reached
4. Implement a content moderation ML pipeline using the OpenAI moderation endpoint to auto-flag spam proposals before they reach the admin queue
5. Add multi-language support for international communities — proposal forms accept any language; admin panel adds translation workflow

Limitations:

- Eventbrite and Lu.ma API integrations add 3–5 days of custom development per platform and require approved API access that can take weeks
- Stripe crowdfunding significantly complicates the data model — funding commitments with conditional charge logic require a dedicated payments microservice

## Gotchas

- **Vote manipulation via logout-and-revote** — AI tools often generate the vote uniqueness check client-side — checking if the user's vote already exists in local state before calling Supabase. A user who logs out and signs in with a different account, or who clears browser storage, bypasses this check entirely. The unique constraint on (user_id, proposal_id) in the database is the only defense that actually holds. Fix: Require authenticated users for all votes (RLS policy: auth.uid() IS NOT NULL on votes INSERT). Rely entirely on the UNIQUE(user_id, proposal_id) database constraint for double-vote prevention — the client should only provide optimistic UI feedback, never enforce the rule.
- **Hot score computed in React instead of Postgres** — The default AI output sorts proposals by vote_score DESC, which means the oldest popular proposals stay at the top forever. When AI generates the time-decay formula at all, it often places it in the React sort function — this means every client computes a slightly different score based on their local clock, and the sort order flickers on re-render. Fix: Compute hot_score as a Postgres generated column using the formula: vote_score / POWER((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600.0) + 2, 1.5). Index on hot_score DESC. The query always uses ORDER BY hot_score DESC — the React component never sorts.
- **Nested comments cause N+1 queries via recursive fetching** — When AI generates nested comments using Supabase's .select() with embedded FK relations, it often attempts to recursively fetch children in a loop — one query per comment. With 100 comments across 20 proposals, this creates 100+ sequential database round trips on a single page load. Fix: Limit nesting to 2 levels and fetch both levels in exactly two queries: one for all root comments (WHERE parent_id IS NULL) and one for all child comments (WHERE parent_id IS NOT NULL AND proposal_id = $1). Merge and nest client-side. This is O(2) queries, not O(N).
- **Per-row Realtime subscriptions flood the connection pool** — AI tools frequently generate a Supabase Realtime subscription per proposal card in the feed list — if 50 proposals are visible, that is 50 simultaneous channel subscriptions. Supabase free tier allows 200 concurrent connections; a single active user with 50 subscriptions consumes 25% of the entire account's connection budget. Fix: Subscribe to a single Realtime channel filtering by table: supabase.channel('proposals').on('postgres_changes', { event: '*', schema: 'public', table: 'event_proposals' }, callback). Update local Tanstack Query cache using queryClient.setQueryData when the channel fires.
- **Admin role checked client-side enables privilege escalation** — The most common admin implementation generated by AI tools stores is_admin as a column in a users table and checks it in React before showing the admin panel. A user who opens browser dev tools and modifies localStorage or React state can make the admin panel visible — and if the RLS policies also use client-readable data for authorization, they can perform admin actions. Fix: Store admin status exclusively in Supabase app_metadata JWT claims, set server-side via the Supabase service role. RLS UPDATE policy checks (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'. The client can read the JWT claim to show/hide UI, but the database enforces the permission independently.

## Best practices

- Compute and index the hot score in Postgres — never sort in React; the algorithm must be consistent across all clients and impervious to clock drift
- Enforce all vote uniqueness rules with database constraints, not application logic — the UNIQUE(user_id, proposal_id) index is the real lock
- Subscribe to one Supabase Realtime channel per table, not per row — use the filter option within a single channel subscription to scope updates
- Limit comment nesting to 2 levels and fetch parent and child comments in exactly two queries — recursive DB queries via PostgREST are fragile and slow
- Use Supabase JWT claims for admin roles and check them in RLS policies — never check client-readable columns for write permissions
- Send approval notification emails in a batch Resend API call rather than individual sends — one API call for 500 voter emails is vastly more reliable than 500 sequential calls
- Show a 'you already voted' indicator before the user tries to vote again — this reduces confusion and eliminates unnecessary failed INSERT attempts
- Convert all date poll options to UTC for storage and use date-fns-tz to display them in each voter's local timezone — distributed communities span multiple timezones

## Frequently asked questions

### How do I prevent the same user from voting multiple times?

Add a UNIQUE constraint on (user_id, proposal_id) in the votes table — this is a database-level lock that cannot be bypassed from the client. Require authentication before voting (RLS policy: auth.uid() IS NOT NULL on INSERT). The client can show optimistic feedback, but the unique constraint is the only defense that matters.

### What is the best algorithm for surfacing popular event proposals?

Use a time-decay hot score: votes / (age_in_hours + 2)^1.5. This matches the Hacker News formula and naturally demotes old proposals as newer ones accumulate votes. Compute it as a Postgres generated column indexed with DESC — never sort in React. A pure vote-count sort lets early proposals dominate the feed indefinitely.

### How do I implement a date poll like Doodle?

Create a date_poll_options table with one row per date option per proposal, and a date_poll_responses table where each user marks each option as is_available (bool) with a UNIQUE(user_id, option_id) constraint. Show availability counts per option and highlight the option with the most 'available' responses. The winning date is selected by count, not first-to-submit.

### How do I handle spam or inappropriate proposals?

Two layers: first, require account authentication before submitting a proposal (RLS: auth.uid() IS NOT NULL on INSERT). Second, give admins the ability to reject proposals without explanation. If volume grows high enough to require automated pre-screening, add OpenAI moderation API check in a Supabase Edge Function on proposal INSERT — flag proposals with high toxicity scores for admin review before they appear in the feed.

### Can non-logged-in users view proposals?

Yes — adjust the SELECT RLS policy on event_proposals to use USING (true) for a public feed, or USING (auth.uid() IS NOT NULL) for an authenticated-only community. Voting and commenting should always require authentication. A read-only public feed often increases sign-up conversion because visitors see the community activity before deciding to join.

### How do I notify users when their proposal is approved?

Trigger a Supabase Edge Function on admin status update to 'approved'. The function queries all users who voted for that proposal (SELECT DISTINCT user_id FROM votes WHERE proposal_id = $1), fetches their emails from auth.users via the service role, and sends a single batched Resend API call. The proposer receives a separate personalized email.

### Can I limit who can submit event proposals?

Yes — gate the INSERT RLS policy with additional checks. Examples: require the user to have been a member for more than 7 days (check created_at on auth.users), require a minimum vote score on previous proposals, or restrict to users with a specific role claim. The simplest production approach is authenticated-only (auth.uid() IS NOT NULL) with an admin ability to ban specific users.

### How do I convert an approved proposal into a confirmed event?

In the admin approval Supabase Edge Function, run an INSERT into your events table using the proposal's title, description, location, and the winning date from the date poll. Set the proposal status to 'approved'. Then send Resend emails to all voters with a link to the new confirmed event page. This creates a clean handoff from community proposal to official calendar entry.

---

Source: https://www.rapidevelopers.com/app-features/crowdsourced-event-planning
© RapidDev — https://www.rapidevelopers.com/app-features/crowdsourced-event-planning
