Community Service
Port :3002. Everything community-scoped — communities themselves,
issues, petitions, representatives, comments, and the notification hub.
Issues live here, alongside petitions and representatives. The MVP colocates them so cross-entity flows (upvote → notification, petition signature → milestone → notification) stay in-process. Extract if scale demands it.
Responsibilities
- Communities — paginated list with name search (
?q=), filter by state / LGA, resolve a specific set by?ids=, create (admin roles only). Member counts are computed at query time fromuser_community_memberships. - Issues — CRUD, upvote toggle, status changes, comment threads.
- Petitions — CRUD, sign, comment threads, milestone notifications.
- Representatives — CRUD, follow / unfollow, comment threads,
official-response notifications to followers, and profile claiming
(
POST /representatives/:id/claim, PLATFORM_ADMIN only). - Notifications — persisted list plus realtime SSE push via an in-process hub.
- Search — global search across communities, issues, petitions, representatives, organizations, consultations, announcements, projects, funding campaigns and representative announcements.
- Discover feed — personalized feed tiered by geographic proximity
(
COMMUNITY→LGA→STATE→COUNTRY). - Uploads — image upload (
POST /uploads, 5 MB max, JPG/PNG/GIF/WEBP), video upload for issue attachments (POST /uploads/video, 10 MB max, MP4/WEBM/MOV), plus a shared public serve route with HTTP Range support.
Package layout
services/community-service/
├── cmd/server/main.go
├── internal/
│ ├── audit/ # Writes to audit_logs (schema owned by identity)
│ ├── communities/ # Communities CRUD
│ ├── discover/ # Discover feed with tier logic
│ ├── domain/models.go
│ ├── issues/ # Issues + comments + upvotes
│ ├── middleware/ # JWTAuth, RequireVerified, RequirePrimaryCommunityMatch, RequireMembershipInCommunity
│ ├── notifications/ # Hub + service + handler (including SSE stream)
│ ├── petitions/ # Petitions + signatures + comments + milestones
│ ├── representatives/ # Reps + follows + comments
│ ├── search/ # Global ILIKE search
│ └── uploads/ # Multipart upload (image + video) + serve, behind a Storage interface
├── migrations/
└── pkg/…
Key concepts
Content flags → hidden placeholders
Comments, issues, and petitions can be flagged. When a moderator
resolves a flag as HIDDEN, the read path in each repository replaces
the content and author name with placeholders — [Removed by moderator]
— without deleting the row. Conversation flow survives; the audit
trail is preserved; the offending payload is gone.
The isHidden field on the DTOs is computed at query time — never
stored. If the flag is dismissed later, the row is unaffected.
Notifications + SSE
- Every notification is persisted to the
notificationstable. - In parallel, the
notifications.Hubbroadcasts to any connected SSE subscribers for that user. - The SSE handler holds a goroutine per open browser tab; a 25-second keep-alive comment prevents intermediaries from closing idle connections.
- The frontend uses
EventSourceto subscribe and re-hydrates unread state on reconnect.
The hub is a map[userID]chan *Notification behind a sync.RWMutex.
It's the simplest thing that works. Replace with Redis pub/sub when the
service is horizontally scaled.
Community scoping middleware
Two middlewares gate community-scoped actions:
RequirePrimaryCommunityMatch— used on creation endpoints (issue, petition, representative). Confirms the caller'sprimaryCommunityIdmatches the community they're creating in.RequireMembershipInCommunity— used on interaction endpoints (comment, upvote, sign). Confirms the caller has a membership row for the target's community.
Both live in internal/middleware/.
Representative profile ownership
representatives.user_id is the account that may publish as that
representative. It is set in exactly three places:
- Approving a representative application (identity-service) — the applicant owns the profile their own application created.
- The
00007backfill, for profiles that predate the column. POST /representatives/:id/claim— PLATFORM_ADMIN only.
The third exists because the first two leave admin-seeded profiles
permanently unclaimable: publishing returned REPRESENTATIVE_UNCLAIMED
telling the user to ask a platform admin, and no endpoint let that admin
help. Provisioning a constituency office in organization-service keys off
the same claim, so the gap blocked campaigns and consultations too.
Claiming never displaces an existing claim — the repository guards on
user_id IS NULL. Reassigning a profile would hand one official's
constituents, and their donors, to a different account; that has to be a
deliberate unlink first. Claiming also does not grant the
REPRESENTATIVE platform role: that comes from the approval flow, which
records a reviewer.
Petition milestones
petitions.Handler.sign fires an extra notification when the new count
crosses 25 %, 50 %, or 100 % of the goal. The crossing is
determined by (newCount - 1 < threshold) && (newCount >= threshold) —
each milestone is emitted exactly once per petition per threshold.
Discover feed tiers
discover.Service.Feed labels each item with a tier based on the
caller's active community:
COMMUNITY— same community as callerLGA— same LGA, different communitySTATE— same state, different LGACOUNTRY— same country, different state
Passing tier= filters to one tier. Passing kind= filters the
discriminator to one of issue, petition, announcement, project,
consultation or campaign; an unrecognised value is treated as no
filter, so a client typo shows the whole feed rather than an empty page
that reads as "nothing is happening near you". Under the hood, it scans
up to 1 000 items from the DB and filters in-memory — fine at MVP scale,
replace with community_id IN (…) when the dataset grows.
Issues and petitions tier by their own community. Announcements tier by
the publishing org. Projects, consultations and campaigns prefer their
communityId when set; campaigns then fall back to their own
state/lga before the org's, because a campaign is often raised for a
specific ward rather than wherever the organization is registered.
Public activity (unauthenticated)
GET /discover/public-activity backs the activity panel on the marketing
homepage. It is the only unauthenticated aggregate on the platform —
anyone on the internet can call it without an account — so it returns the
smallest thing that can honestly be shown: kind, title, status, state/LGA,
timestamp.
Deliberately absent: author names and user ids (a ticker does not need to name the citizen who reported a broken transformer, and a public homepage is a different audience from a signed-in page), bodies, and anything not already public.
It is a separate endpoint rather than a mode of the feed because the feed is authenticated, personalised, and hydrates whole entities plus their organizations — none of which is wanted, and all of which would be a larger surface to get wrong.
Implementation is one query per kind then a merge, not a UNION: the six
kinds live in tables owned by two services with different shapes, and a
UNION would force a lowest-common-denominator projection that breaks the
moment one of them gains a column. internal/discover/public_activity_test.go
seeds every kind in every status against a real Postgres and asserts that no
draft, pending, rejected or archived record is reachable.
An empty list is a valid answer. The homepage renders a real empty state rather than filling the space, and does not claim "Live" over an empty panel.
What discovery will not show
Announcements, consultations, projects and campaigns are owned by
organization-service and read here from the shared database with
TableName() pinned — the same arrangement documented above. For
campaigns the read is an explicit column allow-list, not the whole
model: the review trail (approvalStatus, reviewNote, reviewedById)
is a private conversation between the platform and the organization.
Both surfaces show exactly the statuses that have a public page, and
that list is owned by organization-service — its publicStatuses
allow-list in internal/campaigns/repository.go:
| Status | Public page | In search | In discover feed |
|---|---|---|---|
DRAFT | 404 | no | no |
PENDING_REVIEW | 404 | no | no |
REJECTED | 404 | no | no |
PAUSED | 404 | no | no |
PUBLISHED | yes | yes | yes |
FUNDED | yes | yes | yes |
COMPLETED | yes | yes | yes |
REPORTED | yes | yes | yes |
A rejected campaign is hidden because the title alone would reveal that
an organization asked for money and was refused. PAUSED is hidden for
a plainer reason: GetPublicBySlug returns 404 for it, so surfacing one
in search would be a result that goes nowhere.
If that allow-list changes, change it in organization-service first.
The two queries here mirror it; they are not independent policy. The
allow-list is deliberately an allow-list, so a status added later
defaults to hidden — the safe direction for a surface that asks people
for money.
Media uploads and the Storage interface
internal/uploads sits behind a two-method Storage interface — Save
and Open — with LocalStorage as the only implementation today. The
handlers never touch a filesystem path, so moving to S3/R2/GCS is a new
implementation plus a line in main.go, not a rewrite of the upload and
serve paths.
Local disk is not durable. On an ephemeral filesystem (Cloud Run, a
plain docker run, most PaaS defaults) every uploaded file disappears
when the container is replaced, while the URLs stay in the database —
issues end up pointing at 404s. Mount a persistent volume at the uploads
directory, or swap the implementation, before this carries real traffic.
Images and videos are separate handlers on purpose:
Image (POST /uploads) | Video (POST /uploads/video) | |
|---|---|---|
| Size cap | 5 MB | 10 MB |
| Types | JPG, PNG, GIF, WEBP | MP4, WEBM, MOV |
| Content-Type check | image/* prefix | closed allowlist |
| Gateway budget | Standard (60/min) | VideoUpload (10/hour) |
Both check extension and Content-Type — either alone is trivially
forged. Serving is shared: one GET /uploads/:filename route for both
kinds, using http.ServeContent so Range requests work, which is what
lets a browser seek within a video without downloading the whole file.
Duration and posters are the client's job
Two things the server deliberately does not do:
- Duration. The 20-second cap is enforced in the browser by reading
HTMLVideoElement.durationbefore upload. Checking it server-side would mean ffprobe or a container parser, and server-side media processing is out of scope. So the split is: the server guarantees size and type, the client guarantees duration. A crafted request can get a long clip past us. That gap is accepted — the size cap already bounds the real cost, and duration is a UX rule rather than a safety one. - Poster frames. The browser seeks to ~0.1s, draws to a canvas, and uploads the JPEG through the ordinary image endpoint. The server never decodes a video frame.
On the model, Issue.VideoURLs, VideoPosterURLs, and VideoSizeBytes
are positional — index i of each describes video i. Parallel arrays
are only tolerable because the cap is one video per issue; if that ever
rises, collapse them into a single jsonb array of objects rather than
adding a fourth.
Video lives on issues only. Petitions, comments, consultations, and announcements are text-and-images by product decision, not oversight.
Environment
DATABASE_URLJWT_SECRET(must match gateway + identity)PORT/COMMUNITY_SERVICE_PORT(default3002)