Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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=...`).
20 changes: 20 additions & 0 deletions backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public static class BffJourneyRegistry
/// <summary>Jornada de moderação (work-items, cases, evidências por território).</summary>
public const string Moderation = "moderation";

/// <summary>Jornada de governança (votações do território: listar, criar, votar, fechar, resultados).</summary>
public const string Governance = "governance";

/// <summary>Jornada de chat (conversas, mensagens, participantes).</summary>
public const string Chat = "chat";

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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<JourneyEndpoint>
{
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<JourneyEndpoint>
{
new("conversations/{conversationId}", "GET", "Detalhe da conversa."),
Expand Down Expand Up @@ -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<JourneyEndpoint>
{
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<JourneyEndpoint>
{
new("conversations/{conversationId}", "GET", "Conversa."),
Expand Down Expand Up @@ -422,6 +441,7 @@ public static string GetApiPathBase(string journeyName)
Notifications,
MarketplaceV1,
Moderation,
Governance,
Chat,
Alerts,
Admin
Expand Down
1 change: 1 addition & 0 deletions backend/Arah.Api.Bff/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"notifications": 30,
"marketplace-v1": 60,
"moderation": 45,
"governance": 45,
"chat": 30,
"alerts": 45,
"admin": 30
Expand Down
25 changes: 25 additions & 0 deletions backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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));
}
}
8 changes: 8 additions & 0 deletions frontend/arah.app/lib/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -119,6 +120,13 @@ final goRouterProvider = Provider<GoRouter>((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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
),
],
],
),
Expand Down
82 changes: 82 additions & 0 deletions frontend/arah.app/lib/features/governance/data/models/voting.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<String, dynamic> 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<String, int> counts;

int get totalVotes => counts.values.fold(0, (sum, c) => sum + c);

factory VotingResults.fromJson(Map<String, dynamic> json) {
final resultsRaw = json['results'] as Map<String, dynamic>? ?? const {};
final counts = <String, int>{};
resultsRaw.forEach((key, value) {
counts[key] = (value as num?)?.toInt() ?? 0;
});
return VotingResults(
votingId: (json['votingId'] ?? '').toString(),
counts: counts,
);
}
}
Original file line number Diff line number Diff line change
@@ -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<List<Voting>> 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<Map<String, dynamic>>()
.map(Voting.fromJson)
.toList();
}

/// GET governance/{territoryId}/votings/{votingId}/results
Future<VotingResults> getResults({
required String territoryId,
required String votingId,
}) async {
final response =
await _client.get(_journey, '$territoryId/votings/$votingId/results');
_ensureSuccess(response.statusCode);
final data = response.data;
if (data is! Map<String, dynamic>) {
return VotingResults(votingId: votingId, counts: const {});
}
return VotingResults.fromJson(data);
}

/// POST governance/{territoryId}/votings/{votingId}/vote
Future<void> vote({
required String territoryId,
required String votingId,
required String selectedOption,
}) async {
final response = await _client.post(
_journey,
'$territoryId/votings/$votingId/vote',
body: {'selectedOption': selectedOption},
);
_ensureSuccess(response.statusCode);
}

/// POST governance/{territoryId}/votings
Future<Voting> createVoting({
required String territoryId,
required String type,
required String title,
required String description,
required List<String> options,
required String visibility,
DateTime? startsAtUtc,
DateTime? endsAtUtc,
}) async {
final response = await _client.post(
_journey,
'$territoryId/votings',
body: {
'type': type,
'title': title,
'description': description,
'options': options,
'visibility': visibility,
if (startsAtUtc != null) 'startsAtUtc': startsAtUtc.toIso8601String(),
if (endsAtUtc != null) 'endsAtUtc': endsAtUtc.toIso8601String(),
},
);
_ensureSuccess(response.statusCode);
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException('Resposta inválida ao criar votação.',
statusCode: response.statusCode);
}
return Voting.fromJson(data);
}

/// POST governance/{territoryId}/votings/{votingId}/close
Future<void> closeVoting({
required String territoryId,
required String votingId,
}) async {
final response = await _client.post(
_journey,
'$territoryId/votings/$votingId/close',
);
_ensureSuccess(response.statusCode);
}

void _ensureSuccess(int statusCode) {
if (statusCode < 200 || statusCode >= 300) {
throw ApiException('HTTP $statusCode', statusCode: statusCode);
}
}
}
Loading
Loading