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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ _Feature module implementation (iam, billing, workspace, discovery) in progress.

### Added

- **MVP feature flags** (`feature/mvp-feature-flags`): a typed `features` map in both environment
files (`FeatureKey` union + `FeatureFlags.isEnabled()`), all **off** by default, so the deployed
product shows only the MVP. A `featureGuard()` `canMatch` makes a disabled route behave like an
unknown URL (falls through to `/projects`), and the sidebar, command palette, buttons and cards
drop what it hides: `billing` (billing pages, checkout returns, Upgrade CTA), `usage`,
`integrations` (Jira pages + OAuth callback, job banner, import/push actions), `members` (org +
project members, invitation landing, palette action/hits, ownership transfer), `customRoles`
(project roles), and the `notifications` / `tokens` account placeholders. The sidebar also hides
a **Settings** entry with no reachable sub-page. Flipping a flag restores the feature with no
other change; see `docs/FEATURE-FLAGS.md` (incl. the backend's FREE-plan limits).
- **Discovery — virtual-meeting audio capture** (`feature/system-audio-capture`): the analyst can now
record a Zoom / Google Meet / Teams call, not only a face-to-face meeting. A compact **audio-source
picker** next to the record button chooses **In person (microphone)** or **Virtual meeting (microphone +
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ export const environment = {
production: false,
apiUrl: '',
wsUrl: '',
features: { billing: false, usage: false, integrations: false, /* … */ } satisfies FeatureFlagMap,
} as const;
```

`features` son los feature flags de build: el MVP oculta billing, usage, integraciones (Jira),
miembros/invitaciones, roles personalizados y los placeholders de cuenta. Ponlos en `true` en
**ambos** archivos para reactivarlos — ver [`docs/FEATURE-FLAGS.md`](docs/FEATURE-FLAGS.md).

---

## Arquitectura
Expand Down Expand Up @@ -130,6 +135,7 @@ Detalle en [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|------------------------------------------------------|-----------------------------------------------------|
| [`docs/adr/`](docs/adr/) | Architecture Decision Records (el *por qué*) |
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Docker · AWS S3 + CloudFront · variables de entorno |
| [`docs/FEATURE-FLAGS.md`](docs/FEATURE-FLAGS.md) | Feature flags del MVP y cómo reactivarlos |
| [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md) | Flujo de trabajo, ramas, commits, PR |
| [`CHANGELOG.md`](CHANGELOG.md) | Historial de cambios (Keep a Changelog) |
| [`.github/SECURITY.md`](.github/SECURITY.md) | Política de seguridad y reporte de vulnerabilidades |
Expand Down
74 changes: 74 additions & 0 deletions docs/FEATURE-FLAGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Feature Flags — Reqs-AI Web (Frontend)

The deployed product ships only the **MVP** ("Podar el árbol"): capture a discovery session,
turn it into Gherkin user stories, and ground the AI with the project glossary and constraints.
Everything else stays in the codebase — and in the backend — but is switched off in the UI by a
build-time feature flag. Turning a flag on brings the feature back with **no other code change**.

## Flags

All flags default to `false` in **both** `src/environments/environment.ts` and
`src/environments/environment.prod.ts`.

| Flag | Hides (routes → fall back to `/projects`) | Hides (UI) |
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `billing` | `/settings/billing`, `/billing/success`, `/billing/cancel` | Org settings nav entry · user-menu **Upgrade** CTA · Usage page **Manage plan** link |
| `usage` | `/settings/usage` | Org settings nav entry |
| `integrations` | `/settings/integrations`, `/settings/integrations/jira/callback` (Atlassian OAuth), `/projects/:id/settings/integrations` | Org + project settings nav entries · global Jira job banner (and its job polling / STOMP topic) · backlog **Import from Jira**, **Push all to Jira**, **Push to Jira (n)** · story detail **Push to Jira** · the backlog's Jira-target lookup |
| `members` | `/settings/members`, `/projects/:id/settings/members`, `/invitations/accept` (plus the legacy `/members` redirects) | Org + project settings nav entries · project overview **Members** card · command-palette **Members** action and member search hits · org General **Transfer ownership** card (and its member fetch) |
| `customRoles` | `/projects/:id/settings/roles`, `…/roles/new`, `…/roles/:roleId/edit` | Project settings nav entry (and the settings landing / overview "Settings" card no longer count it) |
| `notifications` | `/account/notifications` | Account nav "Soon" placeholder |
| `tokens` | `/account/tokens` | Account nav "Soon" placeholder |

A hidden route does not match at all (`canMatch` returns `false`), so it behaves exactly like an
unknown URL: the router falls through to the wildcard `**` → `/projects` (query params are kept by
that redirect, as for any unknown URL). No blank page, no "no access" toast, and the lazy chunk is
never downloaded.

The sidebar also hides a **Settings** entry when none of its sub-pages is reachable (e.g. an org
admin while `members` and `integrations` are off), and the org-level entry opens the first
reachable sub-page instead of the owner-only General.

### Decisions

- **Invitation links** (`/invitations/accept`) are behind `members`. While it is off nobody can
send an invitation from the UI, and an owner has no screen to see or remove whoever joins, so a
stale invite link must not add people to an org. Turning `members` on restores the flow.
- **Transfer ownership** (org General settings) is behind `members`: without members there is
nobody to pick, and its empty state asks the owner to invite someone.
- The story status filter still lists `EXPORTED`: it is data a story may already carry, not a link.

## Turning a feature on

1. Set the flag to `true` in **both** environment files (the production build uses
`environment.prod.ts` via `fileReplacements`).
2. Rebuild / redeploy. Nothing else changes: routes, nav entries and buttons come back as they were.

Tests override flags with `provideFeatureFlags({ members: true })` in the TestBed providers.

## Adding a flag

1. Add the key to the `FeatureKey` union in `src/app/core/features/feature-flags.ts`; both
environment files then fail to compile until they define it (`satisfies FeatureFlagMap`).
2. Routes: `canMatch: [featureGuard('key')]`. When the route already has a `canMatch` guard, chain
it — `featureGuard('key', requirePermissionMatch('X'))` — because Angular runs every guard of a
`canMatch` array eagerly.
3. Sidebar: add `feature: 'key'` to the item in `src/app/layout/shell/shell-nav.ts`.
4. Anything else (buttons, cards, palette actions, data fetches):
`inject(FeatureFlags).isEnabled('key')`.
5. Extend the URL lists in `src/app/app.routes.spec.ts` and update this table.

## Backend coupling

Flags are **UI-only**: they are not a security boundary, and every backend endpoint stays live
(role/permission checks still apply server-side).

- **Plan limits are enforced by the backend.** Each new organization gets a FREE subscription and
the workspace module rejects work beyond its limits with `422`: 25 projects
(`PROJECT_PLAN_LIMIT_EXCEEDED`), 50 glossary terms per project
(`GLOSSARY_TERM_PLAN_LIMIT_EXCEEDED`), 10 documents per project
(`PROJECT_DOCUMENT_PLAN_LIMIT_EXCEEDED`) and 3 members (`MEMBER_PLAN_LIMIT_EXCEEDED`). The UI shows
the localized error toast; with `billing` off there is no upgrade path, so raise the FREE limits
server-side if the MVP needs more.
- **AI token usage is metered, not enforced**: the backend records consumption against the plan
quota but no MVP flow is blocked by it, and no frontend guard or banner depends on billing.
145 changes: 145 additions & 0 deletions src/app/app.routes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { Router, provideRouter, withRouterConfig } from '@angular/router';
import { TranslocoTestingModule } from '@jsverse/transloco';
import { of } from 'rxjs';
import { routes } from './app.routes';
import { AuthStore } from './core/auth/auth.store';
import { CURRENT_TERMS_VERSION } from './core/auth/terms';
import { PermissionsStore } from './core/authz/permissions.store';
import { FeatureFlagMap, provideFeatureFlags } from './core/features/feature-flags';
import { TenantContextService } from './core/tenant/tenant-context.service';

/** Every non-MVP URL, each gated by one feature flag. */
const HIDDEN_URLS = [
'/settings/billing',
'/billing/success',
'/billing/cancel',
'/settings/usage',
'/settings/integrations',
'/settings/integrations/jira/callback',
'/projects/p1/settings/integrations',
'/settings/members',
'/projects/p1/settings/members',
'/invitations/accept',
'/projects/p1/settings/roles',
'/projects/p1/settings/roles/new',
'/projects/p1/settings/roles/r1/edit',
'/account/notifications',
'/account/tokens',
];

/** MVP URLs that must keep resolving with every flag off. */
const MVP_URLS = [
'/projects',
'/projects/p1/overview',
'/projects/p1/sessions/history',
'/projects/p1/stories',
'/projects/p1/stories/new',
'/projects/p1/stories/s1',
'/projects/p1/glossary',
'/projects/p1/constraints',
'/projects/p1/settings/general',
'/projects/p1/settings/danger',
'/settings/general',
'/account/profile',
'/account/security',
'/account/appearance',
];

/** The MVP release: every non-MVP feature off (explicit, so the test doesn't follow the env). */
const ALL_OFF: FeatureFlagMap = {
billing: false,
usage: false,
integrations: false,
members: false,
customRoles: false,
notifications: false,
tokens: false,
};

const ALL_ON: FeatureFlagMap = {
billing: true,
usage: true,
integrations: true,
members: true,
customRoles: true,
notifications: true,
tokens: true,
};

/**
* Drives the REAL route table as a signed-in organization owner (stubbed auth, terms and
* permissions, so every role/permission guard passes) and reports where each URL ends up.
* No outlet is mounted, so the lazy pages are loaded but never rendered.
*/
describe('app routes with feature flags', () => {
function setup(flags: FeatureFlagMap): Router {
TestBed.configureTestingModule({
imports: [TranslocoTestingModule.forRoot({ langs: { en: {} } })],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'always' })),
provideFeatureFlags(flags),
{
provide: AuthStore,
useValue: { isAuthenticated: () => true, organizationId: () => 'org-1' },
},
{
provide: TenantContextService,
useValue: { termsVersion: () => CURRENT_TERMS_VERSION },
},
{
provide: PermissionsStore,
useValue: {
loadOrgAuthorization: () => of(undefined),
loadProjectPermissions: () => of(undefined),
isOrgOwner: () => true,
isOrgOwnerOrAdmin: () => true,
has: () => true,
},
},
],
});
return TestBed.inject(Router);
}

async function landing(router: Router, url: string): Promise<string> {
await router.navigateByUrl(url);
return router.url;
}

describe('with the MVP flags (all off)', () => {
it.each(HIDDEN_URLS)('treats %s as an unknown URL (falls back to /projects)', async (url) => {
const router = setup(ALL_OFF);
expect(await landing(router, url)).toBe('/projects');
});

it.each(['/members', '/projects/p1/members'])(
'sends the legacy %s redirect to the fallback too',
async (url) => {
const router = setup(ALL_OFF);
expect(await landing(router, url)).toBe('/projects');
},
);

it.each(MVP_URLS)('still resolves %s', async (url) => {
const router = setup(ALL_OFF);
expect(await landing(router, url)).toBe(url);
});

it('lands the project settings index on General', async () => {
const router = setup(ALL_OFF);
expect(await landing(router, '/projects/p1/settings')).toBe('/projects/p1/settings/general');
});
});

describe('with every flag on', () => {
it.each(HIDDEN_URLS)('resolves %s again', async (url) => {
const router = setup(ALL_ON);
expect(await landing(router, url)).toBe(url);
});
});
});
Loading
Loading