diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..3dd96aaa
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,48 @@
+# AGENTS.md
+
+## Cursor Cloud specific instructions
+
+This monorepo has four products: a .NET 8 backend API + BFF (`backend/`), three web apps
+(`frontend/wiki`, `frontend/portal`, `frontend/devportal`), and a Flutter app (`frontend/arah.app`).
+The VM snapshot already has the toolchains installed (.NET 8 SDK at `/usr/local/dotnet`,
+Flutter 3.27.4 + Dart at `/usr/local/flutter`, both symlinked into `/usr/local/bin`), and the
+startup update script refreshes dependencies. Standard build/test/run commands live in
+`.github/workflows/ci.yml`, `docs/SETUP.md`, `docs/DEVELOPMENT.md`, and the per-app
+`package.json`/`pubspec.yaml`. Notes below are the non-obvious gotchas.
+
+### Backend (.NET 8) — `backend/`, solution `Arah.sln`
+- Use **`Arah.sln`**, not the legacy `Araponga.sln`. All code/namespaces are `Arah.*` even though
+ prose docs still say "Araponga".
+- The API **requires** a JWT signing key to boot, even in Development. Without it `Program.cs`
+ throws `JWT SigningKey must be configured...`. Set env `JWT__SIGNINGKEY=...` before running
+ (any value works in Development/Testing; the literal `dev-only-change-me` is tolerated outside
+ Production with a warning).
+- The API defaults to the **InMemory** persistence provider (`appsettings.json` →
+ `Persistence:Provider`), so it runs with **no Postgres/Redis/MinIO** for local dev and seeds
+ canonical territories on boot. Redis and MinIO are optional (the app falls back to in-memory cache
+ and local-disk media). For realistic persistence set `Persistence__Provider=Postgres` +
+ `ConnectionStrings__Postgres=...` (see `docker-compose.yml`).
+- Run the API: `dotnet run --project backend/Arah.Api` → `http://localhost:5178` (Swagger `/swagger`,
+ health `/health`). `/health` reporting `Degraded` in dev is expected (storage health check with the
+ Local media provider).
+- Run the BFF (needed by the Flutter app): `dotnet run --project backend/Arah.Api.Bff` →
+ `http://localhost:5005`. Point it at the API with `Bff__ApiBaseUrl=http://localhost:5178`. The BFF
+ has no `/health` route; its proxied routes live under `/bff/journeys` and `/api/v2/journeys/*`.
+- Tests: `dotnet test backend/Tests/Arah.Tests/Arah.Tests.csproj` and
+ `dotnet test backend/Tests/Arah.Tests.Bff/Arah.Tests.Bff.csproj`. A couple of tests under
+ `Arah.Tests/Performance` assert latency SLAs (e.g. `< 700ms`) and can flake on a slow/loaded VM —
+ those failures are timing-related, not code regressions.
+
+### Web frontends — `frontend/{wiki,portal,devportal}` (npm)
+- `npm run lint` is **broken** in `wiki` and `portal`: they call `next lint`, which Next.js 16
+ removed, so `next` treats `lint` as a directory and errors. Use `npm run type-check` and
+ `npm test` (jest) instead for those apps.
+- `wiki`: `npm run dev` serves on port **3001** under the **`/wiki`** base path
+ (`http://localhost:3001/wiki`); `/` returns 404. Checks: `npm run type-check`, `npm test`.
+- `portal`: `npm run dev` serves on port **3000** at `/`.
+- `devportal`: static HTML; no dev server. Validate with `npm test` (jest). It is also served by the
+ API at `/devportal`.
+
+### Flutter app — `frontend/arah.app`
+- `flutter pub get`, then `flutter analyze --no-fatal-infos` (CI tolerates info-level lints) and
+ `flutter test`. It talks only to the **BFF**, not the API directly (`--dart-define=BFF_BASE_URL=...`).
diff --git a/backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs b/backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs
index be064675..5ff46666 100644
--- a/backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs
+++ b/backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs
@@ -60,6 +60,9 @@ public static class BffJourneyRegistry
/// Jornada de moderação (work-items, cases, evidências por território).
public const string Moderation = "moderation";
+ /// Jornada de governança (votações do território: listar, criar, votar, fechar, resultados).
+ public const string Governance = "governance";
+
/// Jornada de chat (conversas, mensagens, participantes).
public const string Chat = "chat";
@@ -90,6 +93,7 @@ public static class BffJourneyRegistry
[Notifications] = "api/v1/notifications",
[MarketplaceV1] = "api/v1",
[Moderation] = "api/v1/territories",
+ [Governance] = "api/v1/territories",
[Chat] = "api/v1/chat",
[Alerts] = "api/v1/alerts",
[Admin] = "api/v1/admin"
@@ -258,6 +262,15 @@ public static string GetApiPathBase(string journeyName)
new("{territoryId}/moderation/cases", "GET", "Casos de moderação do território."),
new("{territoryId}/evidences", "GET", "Evidências do território.")
},
+ [Governance] = new List
+ {
+ new("{territoryId}/votings", "GET", "Lista votações do território. Query: status (Open, Closed, Cancelled)."),
+ new("{territoryId}/votings", "POST", "Cria votação no território. Body: type, title, description, options, visibility, startsAtUtc, endsAtUtc."),
+ new("{territoryId}/votings/{votingId}", "GET", "Detalhe de uma votação."),
+ new("{territoryId}/votings/{votingId}/vote", "POST", "Registra um voto. Body: selectedOption."),
+ new("{territoryId}/votings/{votingId}/close", "POST", "Fecha a votação (criador ou curador)."),
+ new("{territoryId}/votings/{votingId}/results", "GET", "Resultados da votação (contagem por opção).")
+ },
[Chat] = new List
{
new("conversations/{conversationId}", "GET", "Detalhe da conversa."),
@@ -384,6 +397,12 @@ public static string GetApiPathBase(string journeyName)
new("{territoryId}/moderation/cases", "GET", "Casos de moderação."),
new("{territoryId}/evidences", "GET", "Evidências.")
},
+ [Governance] = new List
+ {
+ new("{territoryId}/votings", "GET", "Votações do território."),
+ new("{territoryId}/votings/{votingId}", "GET", "Detalhe da votação."),
+ new("{territoryId}/votings/{votingId}/results", "GET", "Resultados da votação.")
+ },
[Chat] = new List
{
new("conversations/{conversationId}", "GET", "Conversa."),
@@ -422,6 +441,7 @@ public static string GetApiPathBase(string journeyName)
Notifications,
MarketplaceV1,
Moderation,
+ Governance,
Chat,
Alerts,
Admin
diff --git a/backend/Arah.Api.Bff/appsettings.json b/backend/Arah.Api.Bff/appsettings.json
index efb37f6a..eaf881fc 100644
--- a/backend/Arah.Api.Bff/appsettings.json
+++ b/backend/Arah.Api.Bff/appsettings.json
@@ -27,6 +27,7 @@
"notifications": 30,
"marketplace-v1": 60,
"moderation": 45,
+ "governance": 45,
"chat": 30,
"alerts": 45,
"admin": 30
diff --git a/backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs b/backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs
index f2a9b984..3f49bf32 100644
--- a/backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs
+++ b/backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs
@@ -63,6 +63,7 @@ public void PathAndQuery_ReturnsExpectedPath(string journey, string subPath, str
[InlineData("notifications", "api/v1/notifications")]
[InlineData("marketplace-v1", "api/v1")]
[InlineData("moderation", "api/v1/territories")]
+ [InlineData("governance", "api/v1/territories")]
[InlineData("chat", "api/v1/chat")]
[InlineData("alerts", "api/v1/alerts")]
[InlineData("admin", "api/v1/admin")]
@@ -235,4 +236,28 @@ public void CacheableGetEndpoints_MarketplaceV1_HasCart()
var list = BffJourneyRegistry.CacheableGetEndpoints[BffJourneyRegistry.MarketplaceV1];
Assert.Contains(list, e => e.Path == "cart");
}
+
+ [Fact]
+ public void AllEndpoints_Governance_ContainsVotingsLifecycle()
+ {
+ var list = BffJourneyRegistry.AllEndpoints[BffJourneyRegistry.Governance];
+ var paths = list.Select(e => e.Path).ToHashSet();
+ Assert.Contains("{territoryId}/votings", paths);
+ Assert.Contains("{territoryId}/votings/{votingId}", paths);
+ Assert.Contains("{territoryId}/votings/{votingId}/vote", paths);
+ Assert.Contains("{territoryId}/votings/{votingId}/close", paths);
+ Assert.Contains("{territoryId}/votings/{votingId}/results", paths);
+ Assert.Contains(list, e => e.Method == "GET");
+ Assert.Contains(list, e => e.Method == "POST");
+ }
+
+ [Fact]
+ public void CacheableGetEndpoints_Governance_HasVotingsReads()
+ {
+ var list = BffJourneyRegistry.CacheableGetEndpoints[BffJourneyRegistry.Governance];
+ var paths = list.Select(e => e.Path).ToHashSet();
+ Assert.Contains("{territoryId}/votings", paths);
+ Assert.Contains("{territoryId}/votings/{votingId}/results", paths);
+ Assert.All(list, e => Assert.Equal("GET", e.Method));
+ }
}
diff --git a/frontend/arah.app/lib/app_router.dart b/frontend/arah.app/lib/app_router.dart
index 7c97f941..0b33e3f7 100644
--- a/frontend/arah.app/lib/app_router.dart
+++ b/frontend/arah.app/lib/app_router.dart
@@ -18,6 +18,7 @@ import 'features/marketplace/presentation/screens/marketplace_screen.dart';
import 'features/chat/presentation/screens/chat_list_screen.dart';
import 'features/chat/presentation/screens/chat_conversation_screen.dart';
import 'features/moderation/presentation/screens/moderation_screen.dart';
+import 'features/governance/presentation/screens/governance_screen.dart';
import 'features/assets/presentation/screens/assets_screen.dart';
import 'features/subscriptions/presentation/screens/subscriptions_screen.dart';
import 'features/onboarding/presentation/screens/onboarding_screen.dart';
@@ -119,6 +120,13 @@ final goRouterProvider = Provider((ref) {
path: '/moderation',
builder: (_, __) => const ModerationScreen(),
),
+ GoRoute(
+ path: '/governance',
+ builder: (_, state) {
+ final territoryId = state.uri.queryParameters['territoryId'];
+ return GovernanceScreen(territoryId: territoryId);
+ },
+ ),
GoRoute(
path: '/assets',
builder: (_, __) => const AssetsScreen(),
diff --git a/frontend/arah.app/lib/features/explore/presentation/screens/explore_screen.dart b/frontend/arah.app/lib/features/explore/presentation/screens/explore_screen.dart
index 237965ab..4dc1bfdb 100644
--- a/frontend/arah.app/lib/features/explore/presentation/screens/explore_screen.dart
+++ b/frontend/arah.app/lib/features/explore/presentation/screens/explore_screen.dart
@@ -49,6 +49,11 @@ class ExploreScreen extends ConsumerWidget {
tooltip: l10n.chat,
onPressed: () => context.push('/chat'),
),
+ IconButton(
+ icon: const Icon(Icons.how_to_vote_outlined),
+ tooltip: l10n.governance,
+ onPressed: () => context.push('/governance?territoryId=$territoryId'),
+ ),
],
],
),
diff --git a/frontend/arah.app/lib/features/governance/data/models/voting.dart b/frontend/arah.app/lib/features/governance/data/models/voting.dart
new file mode 100644
index 00000000..1eeab6f6
--- /dev/null
+++ b/frontend/arah.app/lib/features/governance/data/models/voting.dart
@@ -0,0 +1,82 @@
+/// Votação de governança do território (BFF governance/{territoryId}/votings).
+/// Alinhado a VotingResponse da API.
+class Voting {
+ const Voting({
+ required this.id,
+ required this.territoryId,
+ required this.createdByUserId,
+ required this.type,
+ required this.title,
+ required this.description,
+ required this.options,
+ required this.visibility,
+ required this.status,
+ this.startsAtUtc,
+ this.endsAtUtc,
+ required this.createdAtUtc,
+ required this.updatedAtUtc,
+ });
+
+ final String id;
+ final String territoryId;
+ final String createdByUserId;
+ final String type;
+ final String title;
+ final String description;
+ final List options;
+ final String visibility;
+ final String status; // Open | Closed | Cancelled
+ final DateTime? startsAtUtc;
+ final DateTime? endsAtUtc;
+ final DateTime createdAtUtc;
+ final DateTime updatedAtUtc;
+
+ bool get isOpen => status.toLowerCase() == 'open';
+
+ factory Voting.fromJson(Map json) {
+ final optionsRaw = json['options'] as List? ?? const [];
+ return Voting(
+ id: (json['id'] ?? '').toString(),
+ territoryId: (json['territoryId'] ?? '').toString(),
+ createdByUserId: (json['createdByUserId'] ?? '').toString(),
+ type: json['type'] as String? ?? '',
+ title: json['title'] as String? ?? '',
+ description: json['description'] as String? ?? '',
+ options: optionsRaw.map((e) => e.toString()).toList(),
+ visibility: json['visibility'] as String? ?? '',
+ status: json['status'] as String? ?? 'Open',
+ startsAtUtc: _parseDate(json['startsAtUtc']),
+ endsAtUtc: _parseDate(json['endsAtUtc']),
+ createdAtUtc: _parseDate(json['createdAtUtc']) ?? DateTime.now(),
+ updatedAtUtc: _parseDate(json['updatedAtUtc']) ?? DateTime.now(),
+ );
+ }
+
+ static DateTime? _parseDate(dynamic value) {
+ if (value == null) return null;
+ return DateTime.tryParse(value.toString());
+ }
+}
+
+/// Resultados de uma votação: contagem de votos por opção.
+/// Alinhado a VotingResultsResponse da API.
+class VotingResults {
+ const VotingResults({required this.votingId, required this.counts});
+
+ final String votingId;
+ final Map counts;
+
+ int get totalVotes => counts.values.fold(0, (sum, c) => sum + c);
+
+ factory VotingResults.fromJson(Map json) {
+ final resultsRaw = json['results'] as Map? ?? const {};
+ final counts = {};
+ resultsRaw.forEach((key, value) {
+ counts[key] = (value as num?)?.toInt() ?? 0;
+ });
+ return VotingResults(
+ votingId: (json['votingId'] ?? '').toString(),
+ counts: counts,
+ );
+ }
+}
diff --git a/frontend/arah.app/lib/features/governance/data/repositories/governance_repository.dart b/frontend/arah.app/lib/features/governance/data/repositories/governance_repository.dart
new file mode 100644
index 00000000..69869853
--- /dev/null
+++ b/frontend/arah.app/lib/features/governance/data/repositories/governance_repository.dart
@@ -0,0 +1,112 @@
+import '../../../../core/network/api_exception.dart';
+import '../../../../core/network/bff_client.dart';
+import '../models/voting.dart';
+
+/// Repositório da jornada BFF governance (votações por território).
+/// Proxy para api/v1/territories/{territoryId}/votings na API.
+class GovernanceRepository {
+ GovernanceRepository({required BffClient client}) : _client = client;
+
+ final BffClient _client;
+
+ static const String _journey = 'governance';
+
+ /// GET governance/{territoryId}/votings?status=
+ Future> listVotings(
+ String territoryId, {
+ String? status,
+ }) async {
+ var path = '$territoryId/votings';
+ if (status != null && status.isNotEmpty) {
+ path += '?status=$status';
+ }
+ final response = await _client.get(_journey, path);
+ _ensureSuccess(response.statusCode);
+ final data = response.data;
+ if (data is! List) return const [];
+ return data
+ .whereType