feat: add unified team content management entry in QA
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-22 20:55:08 +02:00
parent a1b553372e
commit 97d51db60a
16 changed files with 322 additions and 19 deletions
@@ -9,6 +9,7 @@ use App\Services\CountryCatalogue;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\LegacyContent;
use App\Services\PortalProfileAccess;
use App\Services\Malware\MalwareScanner;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
@@ -297,6 +298,7 @@ class PortalServicesController extends Controller
public function manage(Request $request, MalwareScanner $scanner): Response
{
$managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']);
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
@@ -304,7 +306,7 @@ class PortalServicesController extends Controller
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
$editorial = $access->editorial($request->user());
$resources = $editorial ? $access->editorialResources($request->user())->orderByDesc('r.updated_at')
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
@@ -313,6 +315,7 @@ class PortalServicesController extends Controller
$resource->revisions = $revisions->get($resource->id, collect())->values();
});
return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
@@ -331,7 +334,7 @@ class PortalServicesController extends Controller
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']);
$disclosureAuthorizationData = $disclosureAuthorization ? json_decode($disclosureAuthorization->after_data, true) : null;
$resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->orderByDesc('r.updated_at')->limit(200)
$resources = $this->filterManagedResources(DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at'), $managementFilters)->orderByDesc('r.updated_at')->limit(200)
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']);
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
@@ -342,6 +345,7 @@ class PortalServicesController extends Controller
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(),
@@ -367,6 +371,39 @@ class PortalServicesController extends Controller
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
}
private function filterManagedResources(Builder $query, array $filters): Builder
{
if (! empty($filters['q'])) {
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
$query->where(fn ($q) => $q->where('r.title', 'ilike', $term)->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)->orWhere('d.file_name', 'ilike', $term));
}
if (! empty($filters['state'])) {
$query->where('r.published', $filters['state'] === 'published');
if ($filters['state'] !== 'published') {
$method = $filters['state'] === 'withdrawn' ? 'whereExists' : 'whereNotExists';
$query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn']));
}
}
return $query;
}
private function contentAssociations(Request $request): array
{
$access = app(\App\Services\DocumentManagementAccess::class);
$resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
: ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null);
$documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
return [
'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
'document' => $documents,
'procedure' => $documents->where('document_type', 'procedure')->values(),
'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
];
}
public function upload(Request $request): RedirectResponse
{
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text'.(app(LegacyContent::class)->available() ? ',application/json' : ''), 'max:10240'],
@@ -399,7 +436,7 @@ class PortalServicesController extends Controller
ScanDocumentForMalware::dispatch($id)->afterCommit();
return to_route('services.manage')->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
return to_route('services.manage', array_filter(['intent' => in_array($request->input('intent'), ['document', 'legislation'], true) ? $request->input('intent') : null]))->with('uploaded_document_id', $id)->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
}
public function resource(Request $request): RedirectResponse
@@ -663,7 +700,7 @@ class PortalServicesController extends Controller
'data_classification' => ['nullable', 'in:internal,public'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'],
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,sector,opportunity'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
'associations.*.target_key' => ['required', 'string', 'max:180'],
'associations.*.review_status' => ['nullable', 'in:pending,confirmed'],
'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'],
@@ -53,6 +53,7 @@ class HandleInertiaRequests extends Middleware
'name' => config('app.name'),
'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'contentManagementHref' => app(\App\Services\ContentManagement::class)->access($request->user()) !== [] ? '/gestao-conteudos' : null,
'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(),
@@ -68,7 +69,7 @@ class HandleInertiaRequests extends Middleware
'create' => ['label' => 'Nova oportunidade', 'href' => '/management/opportunities/create'],
'installationHref' => '/management/opportunity-management-install',
] : null,
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning')],
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')],
'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label'=>'Zonas e parques','href'=>'/management/investment/zones'],
['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'],
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/** Navigation over existing authorisations; never grants a role or publishes content. */
final class ContentManagement
{
public function access(?User $actor): array
{
if (! $actor || ! $actor->hasVerifiedEmail()) return [];
$profiles = app(PortalProfileAccess::class);
$navigation = $profiles->navigation($actor);
$legacy = $profiles->legacyWriteAllowed($actor);
$documents = app(DocumentManagementAccess::class);
$editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor);
$result = [];
if ($legacy || ($navigation['mine'] ?? null) || ($navigation['review'] ?? null)) {
$result['opportunities'] = ['index' => $legacy ? '/management/opportunities' : '/minhas-oportunidades',
'create' => $legacy ? '/management/opportunities/create' : ($navigation['create'] ?? null), 'profileWorkflow' => ! $legacy];
}
if ($upload) $result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor];
return $result;
}
public function view(User $actor): array
{
$access = $this->access($actor);
abort_unless($access !== [], 403);
$counts = [];
if (isset($access['opportunities'])) {
$counts['opportunities'] = app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)
->selectRaw("COALESCE(workflow_context->>'working_state',status) as state, count(*) as total")
->groupByRaw("COALESCE(workflow_context->>'working_state',status)")->pluck('total', 'state');
}
if (isset($access['documents'])) {
$documents = app(DocumentManagementAccess::class);
$query = $documents->full($actor) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
: ($access['documents']['edit'] ? $documents->editorialResources($actor) : null);
$counts['documents'] = $query ? $query->select('r.published', DB::raw('count(*) as total'))->groupBy('r.published')->get()
->mapWithKeys(fn ($row) => [$row->published ? 'published' : 'unpublished' => (int) $row->total]) : [];
}
return ['access' => $access, 'counts' => $counts];
}
}
+5 -2
View File
@@ -184,7 +184,9 @@ class PortalProfileAccess
public function canCreate(User $actor, ?string $organization, string $type): bool
{
$included = in_array($type, OpportunityCanonicalContract::BASE_CATEGORIES, true) || app(OpportunityModularProfiles::class)->enabled();
// Draft preparation is allowed within the assigned modality. Executable
// submission support is checked separately by the canonical contract.
$included = in_array($type, OpportunityManagement::CATEGORIES, true);
// This is creation as an authorized Promotor, never an exceptional SU act.
return $included && $this->available() && $this->scoped($actor, 'promoter', $organization, $type) && ! $this->has($actor, 'auditor');
@@ -370,7 +372,8 @@ class PortalProfileAccess
foreach (json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR) as $type) {
$key = ($grant->organization_id ?? 'individual').'|'.$type;
if (! isset($seen[$key]) && $this->canCreate($actor, $grant->organization_id, $type)) {
$contexts[] = ['organization_id' => $grant->organization_id, 'type' => $type];
$contexts[] = ['organization_id' => $grant->organization_id,
'organization_name' => $grant->organization_id ? DB::table('organizations')->where('id', $grant->organization_id)->value('name') : null, 'type' => $type];
$seen[$key] = true;
}
}
@@ -315,3 +315,7 @@ Situação comum observada: `publication_status=draft`, `validation_status=valid
- Verificação: `php artisan test tests/Feature/PortalServicesTest.php` — 17 testes/497 asserções aprovados em QA; cobrem publicação autorizada com pesquisa textual e filtros combinados, ficha, download, associação ao módulo e links de requisitos com estados confirmados/pendentes. `npm.cmd run build` concluído. O build alerta sobre pacote opcional `fontaine` e chunk `services` acima de 500 KB; sem erro de compilação. Não houve browser autenticado disponível, portanto não se declara publicação, pesquisa ou download públicos concluídos.
- A autorização documental não foi aplicada a zonas, parques, negócios privados ou oportunidades de demonstração. A regra de publicação de parque não exige área confirmada; divergência de área, isoladamente, não é um gate técnico. Se a ficha avançar por decisão própria, deve omitir o total disputado ou mostrar os valores reportados com fonte/escopo, sem alegar disponibilidade. Beluluane, Munhava e Topuito requerem revisão por registo para fonte/localização pública e para retirar ou qualificar a área; Topuito só tem província associada no mestre geográfico, pelo que não se pode anunciar uma associação administrativa a Larde enquanto essa referência não existir. ZEE/ZFI continuam dependentes de acto oficial estruturado e validado, regime e relações territoriais apropriadas; uma área sem confirmação não substitui essas evidências.
- Proposta para as seis oportunidades de demonstração já públicas: preservar os registos e IDs, adicionar origem/tipo `demo` e identificá-los visivelmente como **Exemplo de demonstração**, separar a pesquisa de exemplos da pesquisa de oportunidades reais e excluir os exemplos dos resultados reais por defeito. Não os converter nem retirar em massa; cada registo real exigirá origem e proponente documentados. Nenhuma alteração foi feita nesta execução.
### Referência da entrada única de gestão — QA, 22/10/2026
Nos requisitos existentes `MASTER-9f6dd1d9-0001`, `MASTER-9f6dd1d9-0003` e `MASTER-02f44baf-0002`, acrescentar como evidência delimitada a secção «Entrada única de gestão — preparação em QA, 22/10/2026» do manual de rastreabilidade e `tests/Feature/ContentManagementEntryTest.php`: quatro testes/99 asserções, ClamAV real e reversão transaccional em QA. Origem do objectivo e das regras operacionais: decisão expressa da Tempo; navegação, filtros e recuperação do upload são escolhas de implementação. Reutilizados biblioteca, armazenamento, perfis e histórico. Não altera os critérios de aceitação nem fecha os requisitos integrais: integração/contas reais e categorias não demonstradas conservam o seu estado. Geometrias permanecem separadas.
@@ -2227,3 +2227,17 @@ A ferramenta não observou o menu nativo aberto pelo rato; a tentativa de clicar
### Decisão de integração — 22/10/2026
A Tempo encerrou a revisão de `1d921c9d` com as provas existentes e autorizou integrar a candidata e o registo documental `916819eb`. A interacção exclusivamente pelo rato permanece não observada pela ferramenta, **sem carácter bloqueante**; não constitui falha comprovada do portal, não exige substituir o selector nem repetir ensaios. A ausência de geometrias administrativas de Cabo Delgado permanece independente. Executar o processo Git automático existente, preservando alterações concorrentes; não transportar fixtures QA, publicar dados ou activar regras fiscais.
## Entrada única de gestão — preparação em QA, 22/10/2026
Origem: prioridade expressa da Tempo para a equipa cadastrar oportunidades e carregar, editar e publicar documentos e legislação pela interface, sem depender do Codex. A decisão de 22/10 sobre fontes e execução mantém-se. Geometrias administrativas ficam no saldo separado.
Implementada `/gestao-conteudos`, acessível pelo painel, menu lateral, Balcão e editores. Reutiliza os cadastros, biblioteca, armazenamento, perfis e histórico existentes. Nova oportunidade abre o editor operacional para quem tem contexto de criação; acesso legado é identificado como tal, sem o apresentar como publicação operacional completa. A entrada não atribui papéis. Escolhas técnicas deste delta: navegação agregada, contagens no âmbito do utilizador, pesquisa documental e pré-selecção do ficheiro acabado de carregar. Não são novas regras organizacionais.
Percurso diário: **Gestão de conteúdos e oportunidades → Nova oportunidade**, escolher promotor/modalidade, preencher e guardar; recuperar em **Gerir oportunidades e histórico**. Para documentos: **Novo documento** ou **Nova legislação → Receber documento em privado → Guardar em quarentena → Registar documento na biblioteca**. O original recém-carregado fica seleccionado; título e emitente conhecidos são recuperados. Completar os metadados, guardar, pesquisar o registo e usar **Editar metadados**, **Publicar** ou **Retirar da publicação**. Associações apresentam nomes dos registos existentes. Campos ainda não conhecidos não são inventados. Publicação para consulta não activa cálculos.
Verificação nova: quatro testes, 99 asserções, com transacções revertidas exclusivamente em `simulator_fiscal_delta_20261021_test`, loopback 55441, guarda explícita. Demonstrados acesso de equipa sem administração geral, recusa a utilizador sem autorização e ciclo documental completo (upload, ClamAV real, recuperação por pesquisa, edição, publicação/download, retirada, preservação do hash e quatro revisões). Exemplo documental exclusivamente sintético, sem origem oficial ou valor jurídico. Corrigida a classificação de retirados: o histórico existente usa `unpublished`; o filtro e o rótulo reconhecem também `withdrawn` histórico. TypeScript e build QA passaram.
No navegador, login e MFA normais da conta sintética de equipa; entrada → legislação; entrada → nova oportunidade → guardar preparação incompleta → recarregar, com título recuperado e ficha QA 241. Não foi repetido o workflow integral de revisão/publicação de oportunidades já existente; a prova nova cobre a entrada e a gravação/recuperação. O ensaio documental completo foi HTTP transaccional, não uma prova visual de cada clique. A conta QA tem autorizações explicitamente sintéticas; não comprova as designações das contas reais. Não transportar fixtures, configurações QA ou concessões de acesso.
Provas locais: `outputs/content-management-20261022/entrada-final.png`, `build-final.log`, e `tests/Feature/ContentManagementEntryTest.php`. Estado: candidata em branch `codex/content-management-entry-QA`, ainda sem integração ou instalação na homologação. Não declarar encerrada a autonomia operacional de todas as contas/categorias apenas por esta entrada. Não houve migration, publicação remota, alteração de papéis activos ou activação fiscal.
+5
View File
@@ -26,6 +26,7 @@ export function AppSidebar() {
educationEnabled,
aiEnabled,
opportunityManagement,
contentManagementHref,
profileAccess,
} = usePage<{
auth: Auth;
@@ -34,6 +35,7 @@ export function AppSidebar() {
aiEnabled?: boolean;
profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null;
contentManagementHref?: string | null;
}>().props;
const groups = portalNavigation({
authenticated: !!auth.user,
@@ -48,6 +50,9 @@ export function AppSidebar() {
href: group.children?.find((item) => item.href)?.href ?? '/',
icon: LayoutGrid,
}));
if (contentManagementHref) {
items.unshift({ title: 'Gestão de conteúdos e oportunidades', href: contentManagementHref, icon: LayoutGrid });
}
if (auth.canManagePortal && opportunityManagement) {
items.push({
title: opportunityManagement.manage.label,
@@ -0,0 +1,6 @@
import { Link, usePage } from '@inertiajs/react';
export default function ContentManagementEntry() {
const { contentManagementHref } = usePage<{ contentManagementHref?: string | null }>().props;
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Gestão de conteúdos e oportunidades</Link> : null;
}
+2
View File
@@ -1,4 +1,5 @@
import ProfileNavigation from '@/components/profile-navigation';
import ContentManagementEntry from '@/components/content-management-entry';
import { Head, Link } from '@inertiajs/react';
import { dashboard } from '@/routes';
import type { InvestmentContext } from '@/pages/investor-path/index';
@@ -51,6 +52,7 @@ export default function Dashboard({
<Head title="Painel" />
<h1 className="text-2xl font-semibold">Painel do portal</h1>
<ProfileNavigation />
<ContentManagementEntry />
{summary ? (
<>
{opportunityManagement && (
+2
View File
@@ -1,4 +1,5 @@
import { createOperationKey } from '@/lib/operation-key';
import ContentManagementEntry from '@/components/content-management-entry';
import { Link, useForm, usePage } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { Frame, input, button, card } from '@/pages/investment/shared';
@@ -84,6 +85,7 @@ export default function Panel({
}, [contextTitle, form.data.title]);
return (
<Frame title="Balcão Virtual — acompanhamento">
<ContentManagementEntry />
<nav className="flex flex-wrap gap-4">
<Link href="/balcao-virtual/servicos" className="underline">
Serviços
@@ -0,0 +1,38 @@
import { Link } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { states } from './shared';
type Access = {
opportunities?: {index: string; create: string | null; profileWorkflow: boolean};
documents?: {index: string; edit: boolean};
};
export default function ContentHub({access, counts}: {access: Access; counts: Record<string, Record<string, number>>}) {
return <Frame title="Gestão de conteúdos e oportunidades">
<p>Use os cadastros e a biblioteca existentes. Guardar um rascunho, analisar um ficheiro e publicar são operações distintas. Cada acção respeita a sua autorização actual.</p>
<div className="grid gap-4 md:grid-cols-2">
{access.opportunities && <section className={card}>
<h2 className="text-xl font-semibold">Oportunidades</h2>
<p>Escolha a modalidade, o sector e a localização; preencha, guarde o rascunho e siga as acções de submissão, revisão e publicação disponíveis no seu perfil.</p>
<p>Sector económico e modalidade são escolhas distintas. Pode guardar preparação incompleta; o editor identifica os campos e variantes ainda sem suporte de submissão.</p>
{access.opportunities.create && <Link className={button} href={access.opportunities.create}>Nova oportunidade</Link>}
<Link className="block underline" href={access.opportunities.index}>Gerir oportunidades e histórico</Link>
<ul>{Object.entries(counts.opportunities ?? {}).map(([state, total]) => <li key={state}><Link className="underline" href={`${access.opportunities!.index}?status=${state}`}>{states[state] ?? state}: {total}</Link></li>)}</ul>
<p className="text-sm">As etapas e a independência já definidas para oportunidades mantêm-se. Esta entrada não atribui autorização para rever ou publicar.</p>
{!access.opportunities.profileWorkflow && <p>O seu acesso actual abre o cadastro legado de rascunhos. A submissão, revisão e publicação de novas oportunidades utilizam os perfis operacionais já existentes; a administração de conteúdos não substitui essa designação. Esta entrada não apresenta o rascunho legado como percurso completo de publicação.</p>}
</section>}
{access.documents && <section className={card}>
<h2 className="text-xl font-semibold">Documentos e legislação</h2>
<p>Carregue o original ou reutilize um ficheiro existente; acompanhe a análise, complete os metadados e associações e defina o acesso. Depois publique explicitamente, se autorizado.</p>
<div className="flex flex-wrap gap-3"><Link className={button} href={`${access.documents.index}?intent=document#receber`}>Novo documento</Link>
{access.documents.edit && <Link className={button} href={`${access.documents.index}?intent=legislation#receber`}>Nova legislação</Link>}</div>
<Link className="block underline" href={`${access.documents.index}#conteudos`}>Gerir conteúdos, versões e histórico</Link>
{!access.documents.edit && <p>O seu acesso permite carregar e analisar ficheiros próprios; não inclui cadastro editorial nem publicação.</p>}
<p>Publicados: {counts.documents?.published ?? 0} · Não publicados: {counts.documents?.unpublished ?? 0}. A lista distingue rascunhos de conteúdos retirados através do histórico.</p>
<p className="text-sm">Publicar legislação permite consultá-la. Não a activa como regra executável no simulador.</p>
</section>}
</div>
<section className={card}><h2 className="font-semibold">Como concluir sem apoio técnico</h2>
<ol className="list-decimal space-y-2 pl-5"><li>Abra a acção pretendida e use os campos do editor existente.</li><li>Guarde a preparação. As mensagens indicam o estado e o que precisa de corrigir.</li><li>Reabra pela gestão, pesquise o título e consulte o histórico.</li><li>Use as acções autorizadas de publicação ou retirada. Os originais e as revisões são preservados.</li></ol>
</section>
</Frame>;
}
@@ -1,6 +1,7 @@
import { ClassificationReview } from '@/components/opportunity-classification';
import { createOperationKey } from '@/lib/operation-key';
import { valueText } from '@/lib/value-text';
import { modalityLabels } from '@/components/opportunity-search-filters';
import OpportunityProfileActions from '@/components/opportunity-profile-actions';
import OpportunityContactForm from '@/components/opportunity-contact-form';
import OpportunityCanonicalFields, {
@@ -330,7 +331,7 @@ export default function OpportunityDraft({
{c.organization_name ??
c.organization_id ??
'Âmbito individual'}{' '}
— {c.type}
— {modalityLabels[c.type] ?? c.type}
</option>
))}
</select>
+2
View File
@@ -1,4 +1,5 @@
import ProfileNavigation from '@/components/profile-navigation';
import ContentManagementEntry from '@/components/content-management-entry';
import { Head, Link, usePage } from '@inertiajs/react';
import type { ReactNode } from 'react';
import { cloneElement, isValidElement, useId } from 'react';
@@ -60,6 +61,7 @@ export function Management({
return (
<div className="space-y-6 p-4 md:p-8">
<Head title={title} />
<ContentManagementEntry />
{auth?.canManagePortal && basePath !== '/minhas-oportunidades' ? (
<nav
className="flex flex-wrap gap-4 text-sm underline"
+44 -11
View File
@@ -1,5 +1,5 @@
import { router, useForm } from '@inertiajs/react';
import { useState } from 'react';
import { router, useForm, usePage } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { Management, Field, control, button } from '../management/shared';
type DocumentFile = {
@@ -164,7 +164,11 @@ export default function Manage({
legacyContentEnabled = false,
documentAccessOnly = false,
editorialScopeOnly = false,
managementFilters = {},
associationOptions = {},
}: {
managementFilters?: {q?: string; state?: string; intent?: string};
associationOptions?: Record<string, {value: string | number; label: string}[]>;
legacyContentEnabled?: boolean;
documentAccessOnly?: boolean;
editorialScopeOnly?: boolean;
@@ -206,12 +210,26 @@ export default function Manage({
}) {
const [editingId, setEditingId] = useState<number | null>(null);
const [actionError, setActionError] = useState('');
const [query, setQuery] = useState(managementFilters.q ?? '');
const [stateFilter, setStateFilter] = useState(managementFilters.state ?? '');
const { flash } = usePage<{flash?: {uploaded_document_id?: string}}>().props;
const upload = useForm<{
file: File | null;
original_source: string;
issuer: string;
}>({ file: null, original_source: '', issuer: '' });
const form = useForm<ResourceForm>(emptyResource());
intent: string;
}>({ file: null, original_source: '', issuer: '', intent: managementFilters.intent ?? 'document' });
const form = useForm<ResourceForm>({...emptyResource(), ...(managementFilters.intent === 'legislation' ? {document_type: 'legislation', legislation: true} : {})});
const chooseDocument = (id: string) => {
const document = documents.find((item) => item.id === id);
form.setData((current) => ({...current, document_id: id,
issuer: current.issuer || document?.issuer || '',
title: current.title || document?.file_name || '',
}));
};
useEffect(() => {
if (flash?.uploaded_document_id && !editingId && !form.data.document_id) chooseDocument(flash.uploaded_document_id);
}, [flash?.uploaded_document_id]);
const setAssociation = (
index: number,
key: keyof Association,
@@ -361,6 +379,7 @@ export default function Manage({
))}
</section>
<form
id="receber"
className="space-y-4 rounded border p-5"
onSubmit={(e) => {
e.preventDefault();
@@ -420,6 +439,7 @@ export default function Manage({
</form>
{!documentAccessOnly && <>
{managementFilters.intent === 'legislation' && <p className="rounded border p-4">Nova legislação: reutilize o original, complete diploma/tipo, entidade emitente, número, datas e âmbito. Registe alterações e revogações nas associações. Publicar para consulta não activa cálculos no simulador.</p>}
<form
className="space-y-4 rounded border p-5"
onSubmit={(e) => {
@@ -458,9 +478,7 @@ export default function Manage({
required
className={control}
value={form.data.document_id}
onChange={(e) =>
form.setData('document_id', e.target.value)
}
onChange={(e) => chooseDocument(e.target.value)}
>
<option value="">
Seleccionar ficheiro ou registo existente
@@ -925,6 +943,8 @@ export default function Manage({
'document',
'investment_type',
'legal_source',
'sector',
'opportunity',
'requirement',
]
.filter(
@@ -934,8 +954,8 @@ export default function Manage({
requirementAssociationsEnabled,
)
.map((v) => (
<option key={v}>
{v}
<option key={v} value={v}>
{({theme:'Tema', module:'Módulo',activity:'Actividade',stage:'Etapa',procedure:'Procedimento',entity:'Entidade',territory:'Território',document:'Documento / diploma',investment_type:'Modalidade de investimento',legal_source:'Fonte legal',requirement:'Requisito',sector:'Sector',opportunity:'Oportunidade'} as Record<string,string>)[v] ?? v}
</option>
))}
</select>
@@ -974,6 +994,12 @@ export default function Manage({
),
)}
</select>
) : associationOptions[association.target_type] ? (
<select className={control} required value={association.target_key} onChange={(e) => setAssociation(index, 'target_key', e.target.value)}>
<option value="">Seleccione um registo existente</option>
{association.target_key && !associationOptions[association.target_type].some((o) => String(o.value) === association.target_key) && <option value={association.target_key}>Referência histórica guardada</option>}
{associationOptions[association.target_type].map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : (
<input
required
@@ -1221,13 +1247,20 @@ export default function Manage({
</section>
)}
<h2 className="text-xl font-bold">
<h2 id="conteudos" className="text-xl font-bold">
Registos, pendências e duplicados por rever
</h2>
<form className="flex flex-wrap gap-3 rounded border p-4" aria-label="Pesquisar conteúdos" onSubmit={(e) => {e.preventDefault(); router.get('/management/portal-services', {q:query,state:stateFilter}, {preserveState:true,preserveScroll:true});}}>
<Field label="Pesquisar título, ficheiro, número ou entidade"><input className={control} value={query} onChange={(e) => setQuery(e.target.value)} /></Field>
<Field label="Estado do conteúdo"><select className={control} value={stateFilter} onChange={(e) => setStateFilter(e.target.value)}><option value="">Todos</option><option value="draft">Rascunhos</option><option value="published">Publicados</option><option value="withdrawn">Retirados</option></select></Field>
<button className={button}>Pesquisar conteúdos</button>
</form>
<p>Mostram-se até 200 registos por pesquisa. Refine o título ou o estado para localizar conteúdos anteriores.</p>
{!resources.length && <p role="status">Sem conteúdos correspondentes no seu âmbito de acesso.</p>}
{resources.map((r) => (
<article key={r.id} className="space-y-3 rounded border p-4">
<h3 className="font-bold">
{r.title} · {r.published ? 'Publicado' : 'Rascunho'}
{r.title} · {r.published ? 'Publicado' : r.revisions.some((v) => ['unpublished', 'withdrawn'].includes(v.change_type)) ? 'Retirado' : 'Rascunho'}
</h3>
<p>
Ficheiro: {r.file_name} · {r.file_status} ·{' '}
+1
View File
@@ -62,6 +62,7 @@ Route::get('/explorar/{sectorSlug}', [LegacyContentController::class, 'landing']
Route::get('/{legacySlug}', [LegacyContentController::class, 'article'])->whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/gestao-conteudos', fn (\Illuminate\Http\Request $request) => Inertia::render('management/content-hub', app(\App\Services\ContentManagement::class)->view($request->user())))->name('content-management');
Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk');
Route::post('/parcerias', [PortalServicesController::class, 'partnership'])->middleware('throttle:10,1')->name('services.partnership');
});
@@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\ContentManagement;
use App\Services\PortalProfileAdministration;
use App\Services\OpportunityManagement;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use App\Services\Malware\MalwareScanner;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ContentManagementEntryTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
$this->assertSame(55441, (int) $db->port);
$this->withoutVite();
}
public function test_entry_does_not_grant_access_to_an_investor(): void
{
$user = User::factory()->create();
config(['portal.managers' => [], 'portal.document_managers' => []]);
$this->assertSame([], app(ContentManagement::class)->access($user));
$this->actingAs($user)->get('/gestao-conteudos')->assertForbidden();
}
public function test_existing_manager_gets_reused_destinations_and_document_intent_and_search(): void
{
$user = User::factory()->create();
config(['portal.managers' => [$user->email]]);
$this->actingAs($user)->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page->component('management/content-hub')
->where('access.documents.edit', true)->where('access.opportunities.create', '/management/opportunities/create'));
$this->get('/management/portal-services?intent=legislation&q=not-existing-qa-title')->assertOk()->assertInertia(fn (Assert $page) => $page
->where('managementFilters.intent', 'legislation')->has('associationOptions.sector')->has('associationOptions.stage')->has('resources', 0));
$this->get('/management/portal-services?state=published')->assertOk();
$this->get('/management/portal-services?state=withdrawn')->assertOk();
}
public function test_team_promoter_uses_operational_editor_without_general_administration(): void
{
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true, 'portal.managers' => []]);
$actor = User::factory()->create();
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$org = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $org, 'name' => 'Synthetic QA content team', 'organization_type' => 'private']);
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $actor->id, 'organization_id' => $org, 'role' => 'officer']);
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
'user_id' => $actor->id, 'role' => 'promoter', 'organization_id' => $org,
'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => [], 'source' => 'synthetic-content-entry-test',
]);
$before = DB::table('portal_role_assignments')->count();
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
$this->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page
->where('access.opportunities.create', '/minhas-oportunidades/create')->where('access.opportunities.profileWorkflow', true)->missing('access.documents'));
$this->get('/minhas-oportunidades/create')->assertOk();
$this->get('/management/portal-services')->assertForbidden();
$this->assertSame($before, DB::table('portal_role_assignments')->count());
}
public function test_new_legislation_is_recovered_edited_published_and_withdrawn_with_history(): void
{
Storage::fake('local');
config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
$this->assertTrue(app(MalwareScanner::class)->health()['available'], 'Real ClamD required for this QA demonstration');
$user = User::factory()->create();
config(['portal.managers' => [$user->email]]);
$this->actingAs($user);
$name = 'synthetic-entry-'.Str::uuid().'.txt';
$this->post('/management/portal-services/upload', ['intent' => 'legislation', 'file' => UploadedFile::fake()->createWithContent($name, 'Synthetic QA legislation example, no legal value.')])
->assertSessionHasNoErrors()->assertSessionHas('uploaded_document_id')->assertRedirect('/management/portal-services?intent=legislation');
$document = DB::table('documents')->where('file_name', $name)->first();
$this->assertSame('clean', $document->malware_scan_status);
$payload = ['document_id' => $document->id, 'title' => $name, 'category' => 'Synthetic QA', 'topic' => 'Synthetic QA',
'document_type' => 'legislation', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/synthetic-qa', 'language' => 'pt', 'version_label' => 'QA-1',
'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'proposed',
'rights_status' => 'authorized', 'data_classification' => 'public', 'source_reference' => 'Synthetic QA example, no official provenance or legal value.',
'legislation' => true, 'essential' => false, 'independent_review' => false, 'associations' => []];
$this->post('/management/portal-services/resources', $payload)->assertRedirect()->assertSessionHasNoErrors();
$resource = DB::table('portal_resources')->where('document_id', $document->id)->first();
$this->assertNotNull($resource);
$this->assertSame($name, $resource->title);
$this->get('/management/portal-services?q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 1)->where('resources.0.title', $name));
$this->put('/management/portal-services/resources/'.$resource->id, [...$payload, 'title' => $name.' edited', 'metadata_version' => $resource->metadata_version])->assertSessionHasNoErrors();
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
$this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload($name);
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => false])->assertSessionHasNoErrors();
$this->get('/management/portal-services?state=withdrawn&q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 1));
$this->get('/management/portal-services?state=draft&q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 0));
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
$this->assertDatabaseHas('documents', ['id' => $document->id, 'sha256' => $document->sha256]);
$this->assertSame(4, DB::table('portal_resource_revisions')->where('resource_id', $resource->id)->count());
}
}