Kelompok should be API-first and CLI-first.
The web app, automation jobs, service integrations, and future AI workflows should use the same core API and command interfaces.
- Version all public endpoints under
/api/v1 - Return consistent response shapes
- Use cursor pagination for large public lists
- Keep public endpoints readable without authentication
- Require authentication for claims, edits, campaigns, and admin actions
- Make claim, import, and enrichment operations auditable
- Publish OpenAPI docs
- Expose plugin-safe ingestion endpoints and commands
- Return public DTOs instead of raw database models
- Keep claim-only contact data, source evidence, raw imports, and private plugin metadata out of public responses
The implemented public, auth, and /api/v1/org-admin routes are published as
an OpenAPI 3.1 artifact at docs/openapi.yaml. Treat it as
the contract for the alpha line: it reflects only routes that are wired up in
internal/httpapi/server.go, not the aspirational endpoint lists below.
The file ships with the source tree, so any tag, branch, or commit can be inspected directly:
# from a local checkout
cat docs/openapi.yaml
# from GitHub (raw contents of a tag or branch)
curl -L https://raw.githubusercontent.com/pendig/kelompok/main/docs/openapi.yaml -o openapi.yamlDrop openapi.yaml into Swagger UI, Redocly, Insomnia, Postman, or any other
OpenAPI-aware client to inspect the contract without reading source.
- Edit
docs/openapi.yamltogether with the matching handler change. - Update the
RegisteredRoutesinventory ininternal/httpapi/server.gowhen adding or removing routes. - Run
go test ./.... The contract smoke tests ininternal/httpapi/openapi_contract_test.gofail when the YAML and the router drift apart, when documented response envelopes change, or when the document loses itsopenapi,info,paths, orcomponentsheaders. - Bump
info.versionindocs/openapi.yamlfor any breaking change.
The contract documents only routes that are implemented today:
GET /healthzandGET /readyz- The public read API for organizations, posts, and impact reports
- The public organization claim intake (
POST /api/v1/organizations/{slug}/claims) - The auth endpoints (
/api/v1/auth/{register,login,logout,me}) - The
/api/v1/org-adminCRM surface (organizations, relationships, claims, audit logs, members, posts, impact reports)
Endpoint lists below also describe routes that are planned but not yet
implemented (events, donation campaigns, claim email/instagram verification,
etc.). Those are intentionally omitted from openapi.yaml until the matching
handler ships, so consumers who treat the artifact as the contract never see
a route that does not exist in production.
Recommended success shape:
{
"data": {},
"meta": {},
"message": "ok"
}Recommended error shape:
{
"error": {
"code": "organization_not_found",
"message": "Organization not found",
"details": {}
}
}Public endpoints must be treated as an explicit allowlist.
They may include public organization profile fields, public post fields, and public impact metrics. They must not expose internal UUIDs, claim verification emails, raw source records, private evidence, credentials, tokens, or plugin-private metadata.
Dynamic JSON fields are filtered before they leave the API. If a plugin or import pipeline needs to keep raw evidence, it should store that data in internal tables or private JSON fields and expose only reviewed public fields through the stable response DTO.
Organizations:
GET /api/v1/organizations
GET /api/v1/organizations/{slug}
GET /api/v1/organizations/{slug}/events
GET /api/v1/organizations/{slug}/donations
GET /api/v1/organizations/{slug}/impact-reports
GET /api/v1/organizations/{slug}/posts
GET /api/v1/organizations/{slug}/posts/{post_slug}
GET /api/v1/organizations/{slug}/sdgs
Implemented in the first public read API slice:
GET /api/v1/organizations
GET /api/v1/organizations/{slug}
POST /api/v1/organizations/{slug}/claims
GET /api/v1/organizations/{slug}/posts
GET /api/v1/organizations/{slug}/posts/{post_slug}
GET /api/v1/organizations/{slug}/impact-reports
Posts:
GET /api/v1/posts
GET /api/v1/posts/{slug}
GET /api/v1/post-categories
GET /api/v1/post-tags
Implemented in the first public read API slice:
GET /api/v1/posts
GET /api/v1/posts/{slug}
Events:
GET /api/v1/events
GET /api/v1/events/{slug}
POST /api/v1/events/{event_id}/registrations
Donation campaigns:
GET /api/v1/donation-campaigns
GET /api/v1/donation-campaigns/{slug}
GET /api/v1/donation-campaigns/{slug}/reports
Auth:
POST /api/v1/auth/register
POST /api/v1/auth/login
POST /api/v1/auth/logout
GET /api/v1/auth/me
PATCH /api/v1/auth/me
PATCH /api/v1/auth/me accepts { "name": "..." } and updates the signed-in user's display name. The endpoint requires a Bearer session token and returns the same shape as GET /api/v1/auth/me.
Claims:
POST /api/v1/organizations/{slug}/claims
GET /api/v1/claims
GET /api/v1/claims/{id}
POST /api/v1/claims/{id}/verify-email
POST /api/v1/claims/{id}/verify-instagram
POST /api/v1/admin/claims/{id}/approve
POST /api/v1/admin/claims/{id}/reject
Organization management:
GET /api/v1/org-admin/organizations
POST /api/v1/org-admin/organizations
GET /api/v1/org-admin/organizations/{slug}
PATCH /api/v1/org-admin/organizations/{slug}
GET /api/v1/org-admin/organizations/{slug}/relationships
POST /api/v1/org-admin/organization-relationships
PATCH /api/v1/org-admin/organization-relationships/{id}
DELETE /api/v1/org-admin/organization-relationships/{id}
GET /api/v1/org-admin/organizations/{slug}/claims
POST /api/v1/org-admin/claims/{id}/approve
POST /api/v1/org-admin/claims/{id}/reject
GET /api/v1/org-admin/organizations/{slug}/audit-logs
GET /api/v1/org-admin/organizations/{slug}/members
POST /api/v1/org-admin/organizations/{slug}/members
PATCH /api/v1/org-admin/members/{id}
DELETE /api/v1/org-admin/members/{id}
GET /api/v1/org-admin/impact-reports
POST /api/v1/org-admin/impact-reports
PATCH /api/v1/org-admin/impact-reports/{id}
POST /api/v1/org-admin/impact-reports/{id}/publish
POST /api/v1/org-admin/impact-reports/{id}/archive
For relationship updates, omit started_at or ended_at to keep the existing date, or send the field as null to clear it.
POST /api/v1/org-admin/organizations and PATCH /api/v1/org-admin/organizations/{slug} accept the release admin organization fields: slug, name, legal_name, description, history, country, region, city, website_url, official_email, claim_status, profile_data, source_data, sdgs_data, and impact_data.
Create organization is an admin-only operation. Public account/onboarding flows
must not call it without an authenticated admin/session context. The create
contract requires name; if slug is omitted it is normalized from name, and
claim_status defaults to unclaimed. Duplicate slugs return HTTP 409 with
organization_slug_taken. Invalid create payloads return HTTP 400 with stable
codes: organization_name_required, organization_slug_required,
organization_claim_status_invalid, organization_official_email_invalid, or
organization_json_invalid.
Post management:
GET /api/v1/org-admin/posts
POST /api/v1/org-admin/posts
PATCH /api/v1/org-admin/posts/{id}
POST /api/v1/org-admin/posts/{id}/publish
POST /api/v1/org-admin/posts/{id}/archive
The current alpha admin endpoints accept either a real user session from POST /api/v1/auth/login or the operations fallback KELOMPOK_ADMIN_API_KEY, provided as X-Kelompok-Admin-Key.
KELOMPOK_ADMIN_ORGANIZATION_SLUGS can restrict fallback key access to specific organizations. Scoped keys must use organization-scoped routes or provide a matching organization_slug where supported; global list routes are blocked for scoped keys unless a valid organization_slug is provided. User sessions are checked against organization roles, and non-superadmin users must use organization-scoped routes.
The static key gate remains intentionally small and self-hosting friendly. Prefer user login and organization roles for normal admin UI workflows.
Global user roles are deliberately narrow. superadmin can operate across all
organizations and use global admin routes. Non-superadmin users must be checked
against organization_user_roles and stay inside organization-scoped routes.
| Actor / role | Scope | Manage org profile/content | Create related org | Review related claims | View audit | Admin override |
|---|---|---|---|---|---|---|
superadmin |
Global | Yes | Yes | Yes | Yes | Yes |
Org owner |
Own organization | Yes | Yes | Yes | Yes | No |
Org admin |
Own organization | Yes | Yes | Yes | Yes | No |
Org member |
Own organization | No | No | No | No | No |
Org viewer |
Own organization | No | No | No | No | No |
| Scoped admin key | Configured slugs | Yes | Yes, when one side is allowed | Yes, for allowed org routes | Yes | Operational fallback only |
Backend guards treat org owner and admin as the canonical manageable roles.
Frontend console navigation must use the same rule, so member and viewer
roles can see their account association but are not linked into /admin.
Event management:
POST /api/v1/org-admin/events
PATCH /api/v1/org-admin/events/{id}
POST /api/v1/org-admin/events/{id}/ticket-types
GET /api/v1/org-admin/events/{id}/registrations
Donor management:
POST /api/v1/org-admin/donation-campaigns
PATCH /api/v1/org-admin/donation-campaigns/{id}
POST /api/v1/org-admin/donation-campaigns/{id}/reports
PATCH /api/v1/org-admin/donation-reports/{id}
The CLI should be useful for:
- Local development
- Self-hosted maintenance
- Data imports
- Import and enrichment jobs
- Claim operations
- Exporting data
- Future AI agent workflows
CLI commands should support:
--jsonoutput--dry-run--limit--source--since- clear exit codes
Server and database:
kelompok serve
kelompok migrate up
kelompok migrate down
kelompok seed
kelompok health
Implemented early:
kelompok seed demo
Organization data:
kelompok org list --json
kelompok org create --name "Green Foundation" --slug green-foundation --official-email hello@example.org --source-data '{"source":"manual"}' --sdgs-data '{"primary":["13"]}'
kelompok org import --file organizations.csv
kelompok org search "climate foundation"
kelompok org show {slug} --json
kelompok org claim {slug} --email admin@example.org
kelompok org export --format json
Members:
kelompok member list --organization {slug} --json
kelompok member create --organization {slug} --name "Aisha" --position "Chair"
kelompok member import --file members.csv --organization {slug}
kelompok member export --organization {slug} --format json
Organization relationships:
kelompok rel list --organization {slug} --json
kelompok rel create --parent pp-muhammadiyah --child pw-muhammadiyah-dki --type structural_parent
kelompok rel create --parent muhammadiyah --child ipm --type autonomous_body --label "Autonomous student organization"
kelompok rel remove --id {relationship_id}
Posts:
kelompok post import --file posts.csv --organization {slug}
kelompok post publish {id}
kelompok post archive {id}
kelompok post export --organization {slug} --format json
Imports and source operations:
kelompok source add --type website --url https://example.org
kelompok source normalize --source-record {id}
kelompok source match --dry-run
Plugins:
kelompok plugin list
kelompok plugin info {plugin}
kelompok plugin run {plugin} --job import-organizations --file organizations.csv
kelompok plugin run {plugin} --job import-members --organization {slug}
kelompok plugin run {plugin} --job import-posts --organization {slug}
kelompok plugin run {plugin} --job import-events --organization {slug}
Events:
kelompok event import --file events.csv
kelompok event publish {id}
Donor reports:
kelompok donor campaign create
kelompok donor report publish {id}
kelompok donor export --organization {slug}
Admin:
kelompok admin user create
kelompok admin claim approve {claim_id}
kelompok admin claim reject {claim_id}
kelompok admin audit show --entity organization:{id}
Implemented in PEN-27. Admins can drive claim review fully from the CLI without
opening the web UI. All commands honor --json for stable, automation-friendly
output and exit non-zero on validation, lookup, or database errors.
kelompok claim list [--organization <slug>] [--status pending|approved|rejected|all] [--limit 50] [--json]
kelompok claim pending [--organization <slug>] [--limit 50] [--json]
kelompok claim update-status --id <claim_id> --decision approve|reject [--reviewer-user-id <uuid>] [--dry-run] [--json]
kelompok claim list and kelompok claim pending:
- Human (default): tab-separated columns
id\torganization_slug\tstatus\tmethod\ttarget\tcreated_at\treviewed_at\treviewed_by_user_id(-is rendered for unreviewed claims). Empty result writes nothing. - JSON (
--json): array of claim objects; each item is the existingClaimRequestJSON shape withorganization_slugandorganization_nameappended. Order is bycreated_atdescending.
kelompok claim update-status:
- Human (default): one line per call —
claim: <applied|dry-run> decision=<approve|reject> would_become=<approved|rejected> id=<id> organization_slug=<slug> current_status=<state> reviewer_user_id=<uuid|-> - JSON (
--json): single object with stable keys{ "dry_run", "decision", "reviewer_user_id", "would_become_status", "claim" }. On--dry-runthe embeddedclaimreflects the current (pre-change) state; on a real run it reflects the post-update state.would_become_statusis always the status the claim would land on.
List the pending review queue across every organization:
kelompok claim pending --json | jq '.[] | {id, organization_slug, target, created_at}'Approve a single claim, but verify first with a dry-run:
kelompok claim update-status --id $CLAIM_ID --decision approve --dry-run --json
kelompok claim update-status --id $CLAIM_ID --decision approve --reviewer-user-id $ADMIN_USER_ID --jsonReject every pending claim for one organization (manual for-loop pattern; the CLI deliberately keeps mutations one-claim-at-a-time so each is independently auditable):
kelompok claim list --status pending --organization green-foundation --json \
| jq -r '.[].id' \
| while read -r id; do
kelompok claim update-status --id "$id" --decision reject --reviewer-user-id "$ADMIN_USER_ID"
done0— success1— any validation, lookup, or database error (includingclaim_not_found,claim_not_pending, invalid status / decision values, or a non-positive--limit). Stderr carries the human-readable reason.
For future AI usage, CLI output should be deterministic and machine-readable.
Example:
kelompok org show green-foundation --json
Should return:
{
"id": "org_123",
"slug": "green-foundation",
"name": "Green Foundation",
"claim_status": "unclaimed",
"public_url": "https://example.org/o/green-foundation",
"sdgs": ["13", "15"],
"sources": [
{
"type": "website",
"url": "https://green.example.org"
}
]
}