Skip to main content

Community Service

Port :3002. Everything community-scoped — communities themselves, issues, petitions, representatives, comments, and the notification hub.

"There's no separate Issue Service"

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 from user_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 notifications table.
  • In parallel, the notifications.Hub broadcasts 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 EventSource to 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's primaryCommunityId matches 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:

  1. Approving a representative application (identity-service) — the applicant owns the profile their own application created.
  2. The 00007 backfill, for profiles that predate the column.
  3. 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 caller
  • LGA — same LGA, different community
  • STATE — same state, different LGA
  • COUNTRY — 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:

StatusPublic pageIn searchIn discover feed
DRAFT404nono
PENDING_REVIEW404nono
REJECTED404nono
PAUSED404nono
PUBLISHEDyesyesyes
FUNDEDyesyesyes
COMPLETEDyesyesyes
REPORTEDyesyesyes

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 cap5 MB10 MB
TypesJPG, PNG, GIF, WEBPMP4, WEBM, MOV
Content-Type checkimage/* prefixclosed allowlist
Gateway budgetStandard (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.duration before 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_URL
  • JWT_SECRET (must match gateway + identity)
  • PORT / COMMUNITY_SERVICE_PORT (default 3002)