first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

31
.claude/settings.json Normal file
View File

@@ -0,0 +1,31 @@
{
"extraKnownMarketplaces": {
"nx-claude-plugins": {
"source": {
"source": "github",
"repo": "nrwl/nx-ai-agents-config"
}
}
},
"enabledPlugins": {
"nx@nx-claude-plugins": true
},
"sandbox": {
"filesystem": {
"allowRead": [
"/tmp/.nx",
"~/.nx"
],
"allowWrite": [
"/tmp/.nx",
"~/.nx"
]
},
"network": {
"allowUnixSockets": [
"/tmp/.nx",
"~/.nx"
]
}
}
}

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
tmp
coverage
.nx
.git
.vscode
.claude
.env
*.log

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
PORT=3000
# Write model (commands)
POSTGRES_URL=postgres://taskstream:taskstream@localhost:5432/taskstream
# Creates/updates tables from the entities. Use migrations in production.
POSTGRES_SYNCHRONIZE=true
# Read model (queries)
MONGO_URL=mongodb://localhost:27017/taskstream_read
# Event stream (write → read), GraphQL pub/sub and session cache
REDIS_URL=redis://localhost:6379
EVENT_STREAM_KEY=task-stream:events
# Set to false on API-only instances when the events bridge (projections) runs elsewhere
EVENTS_BRIDGE_ENABLED=true
# Pireon identity server (https://oauth.pireon.dev/api/documentation)
PIREON_BASE_URL=https://oauth.pireon.dev
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
PIREON_WEBHOOK_SECRET=
# OAuth client registered for task-stream (POST /api/clients). Needed by the web app to sign in.
# Redirect URI to register: http://localhost:4200/auth/callback
PIREON_CLIENT_ID=
PIREON_CLIENT_SECRET=
PIREON_SCOPES=users:read
PIREON_PASSWORD_GRANT=true
AUTH_SESSION_TTL_SECONDS=60
GRAPHQL_PLAYGROUND=true

55
.gitignore vendored Normal file
View File

@@ -0,0 +1,55 @@
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# compiled output
dist
tmp
out-tsc
# dependencies
node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data
.nx/migrate-runs
.claude/worktrees
.claude/settings.local.json
.nx/polygraph
# env
.env
.angular
__screenshots__/
vitest.config.*.timestamp*!apps/web/.env

16
.npmrc Normal file
View File

@@ -0,0 +1,16 @@
# Default registry stays npmjs on purpose.
#
# nexus.pireon.dev/repository/npm-releases is a *hosted* release repo, not a
# proxy: it answers 404 for anything published on npmjs (verified with tslib).
# Pointing `registry` at it would break every install in this workspace.
registry=https://registry.npmjs.org/
# Pireon's own packages (@pi/*) come from Nexus.
@pi:registry=https://nexus.pireon.dev/repository/npm-releases/
//nexus.pireon.dev/repository/npm-releases/:always-auth=true
# The token is deliberately not here. Locally it already lives in ~/.npmrc; in
# CI, write it before installing:
# npm config set //nexus.pireon.dev/repository/npm-releases/:_authToken "$NEXUS_TOKEN"
# Putting `${NEXUS_TOKEN}` in this file instead would make npm fail outright on
# any machine where the variable is unset — including this one.

7
.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/workspace-data
package-lock.json
.angular

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"singleQuote": true
}

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"firsttris.vscode-jest-runner"
]
}

29
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,29 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug api with Nx",
"runtimeExecutable": "npx",
"runtimeArgs": [
"nx",
"serve",
"api"
],
"env": {
"NODE_OPTIONS": "--inspect=9229"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": [
"<node_internals>/**"
],
"sourceMaps": true,
"outFiles": [
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
"!**/node_modules/**"
]
}
]
}

24
CLAUDE.md Normal file
View File

@@ -0,0 +1,24 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI
- You have access to the Nx MCP server and its tools, use them to help the user
- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.
- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure
## Scaffolding & Generators
- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools
## When to use nx_docs
- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know
- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
<!-- nx configuration end-->

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
# TaskStream images (one Dockerfile, two targets):
# docker compose up -d --build → api + web + infrastructure
#
# @pi/ui comes from Pireon's private Nexus. Its token is passed as a build
# secret (compose: `secrets.npmrc` → ~/.npmrc) and never lands in a layer.
# ---- Workspace dependencies (shared by both builds) --------------------
FROM node:22-alpine AS deps
WORKDIR /workspace
ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true
COPY package.json package-lock.json .npmrc ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --no-audit --no-fund
# ---- API: Nest bundle + pruned package.json / lockfile -----------------
FROM deps AS build-api
COPY . .
RUN npx nx run api:prune
FROM node:22-alpine AS api
WORKDIR /app
ENV NODE_ENV=production PORT=3000
# Only the API's runtime dependencies (apps/api/package.json): no frontend
# packages, nothing from the private registry.
COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
COPY --from=build-api /workspace/dist/apps/api/main.js ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
CMD wget -qO- http://127.0.0.1:3000/api/health > /dev/null || exit 1
CMD ["node", "main.js"]
# ---- Web: Angular SPA behind nginx (proxies /api and /graphql) ---------
FROM deps AS build-web
COPY . .
RUN npx nx build web --configuration=production
FROM nginx:1.27-alpine AS web
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build-web /workspace/dist/apps/web/browser /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/ > /dev/null || exit 1

View File

@@ -0,0 +1,408 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import {
IdentityProvider,
InvalidAccessTokenError,
} from '@task-stream/api/identity/application';
import { User, UserFactory } from '@task-stream/api/identity/domain';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
import { Client, createClient } from 'graphql-ws';
import WebSocket from 'ws';
process.env['POSTGRES_URL'] ??=
'postgres://taskstream:taskstream@localhost:5432/taskstream';
process.env['POSTGRES_SYNCHRONIZE'] = 'true';
process.env['MONGO_URL'] ??= 'mongodb://localhost:27017/taskstream_e2e';
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
process.env['GRAPHQL_SCHEMA_FILE'] = '';
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
const { AppModule } = require('../src/app/app.module');
/** Pireon is replaced by fixed tokens: the rest of the stack is real. */
const users = new UserFactory();
const person = (id: string, name: string, email: string) =>
users.create({ id, name, email, isActive: true });
const people: Record<string, User> = {
'token-ana': person('101', 'Ana (lead)', 'ana@x.dev'),
'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'),
'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'),
'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'),
};
const byId = (id: string) =>
Object.values(people).find((p) => p.id === id) ?? null;
const fakePireon: Pick<IdentityProvider, 'whoAmI' | 'findUser' | 'listUsers'> =
{
whoAmI: async (token) => {
const user = people[token];
if (!user) throw new InvalidAccessTokenError();
return user;
},
findUser: async (id) => byId(id),
listUsers: async () => Object.values(people),
};
describe('task-stream (e2e)', () => {
let app: INestApplication;
let url: string;
const clients: Client[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
async function gql<T = Record<string, any>>(
token: string,
query: string,
variables?: object,
): Promise<T> {
const response = await fetch(`${url}/graphql`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query, variables }),
});
const body = await response.json();
if (body.errors)
throw new Error(
body.errors.map((e: { message: string }) => e.message).join('; '),
);
return body.data;
}
/** The read model is eventually consistent: poll until it catches up. */
async function eventually<T>(
read: () => Promise<T>,
ok: (value: T) => boolean,
): Promise<T> {
const deadline = Date.now() + 10_000;
let last: unknown;
while (Date.now() < deadline) {
try {
const value = await read();
if (ok(value)) return value;
last = value;
} catch (error) {
last = error;
}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`Read model did not converge: ${JSON.stringify(last)}`);
}
function subscribe<T>(
token: string | null,
query: string,
variables: Record<string, unknown>,
) {
const client = createClient({
url: url.replace('http', 'ws') + '/graphql',
webSocketImpl: WebSocket,
connectionParams: token ? { authorization: `Bearer ${token}` } : {},
retryAttempts: 0,
});
clients.push(client);
const received: T[] = [];
const errors: unknown[] = [];
client.subscribe<T>(
{ query, variables },
{
next: (msg) =>
msg.errors ? errors.push(msg.errors) : received.push(msg.data as T),
error: (e) => errors.push(e),
complete: () => undefined,
},
);
return { received, errors };
}
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(IdentityProvider)
.useValue(fakePireon)
.compile();
app = moduleRef.createNestApplication({ rawBody: true });
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true }),
);
app.useGlobalFilters(new DomainErrorFilter());
await app.listen(0);
url = await app.getUrl();
url = url.replace('[::1]', 'localhost');
});
afterAll(async () => {
await Promise.all(clients.map((c) => c.dispose()));
await app?.close();
});
it('runs the whole ticket lifecycle with CQRS and realtime updates', async () => {
const key = `E${Date.now().toString(36).slice(-5).toUpperCase()}`;
// --- Project & team (identity comes from Pireon) ---
const { createProject } = await gql(
'token-ana',
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id key version } }`,
{
i: { key, name: 'Checkout revamp' },
},
);
const projectId = createProject.id;
expect(createProject.key).toBe(key);
await eventually(
() =>
gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, {
k: key,
}),
(d) => !!d.project,
);
for (const userId of ['102', '103']) {
await gql(
'token-ana',
`mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`,
{
i: { projectId, userId, role: 'MEMBER' },
},
);
}
// Subscriptions are authorised against the read model: wait for Bob's membership.
await eventually(
async () => (await gql('token-bob', `{ myProjects { id } }`)).myProjects,
(p) => p.some((x: { id: string }) => x.id === projectId),
);
// --- Realtime: Bob watches the board ---
const board = subscribe<{
issueChanged: { action: string; issue: { key: string; status: string } };
}>(
'token-bob',
`subscription($p: ID!) { issueChanged(projectId: $p) { action version issue { key status assignee { name } } } }`,
{ p: projectId },
);
const feed = subscribe<{ activityAdded: { summary: string } }>(
'token-ana',
`subscription($p: ID!) { activityAdded(projectId: $p) { summary } }`,
{ p: projectId },
);
const intruder = subscribe(
'token-eve',
`subscription($p: ID!) { issueChanged(projectId: $p) { action } }`,
{ p: projectId },
);
const anonymous = subscribe(
null,
`subscription($p: ID!) { issueChanged(projectId: $p) { action } }`,
{ p: projectId },
);
await new Promise((r) => setTimeout(r, 300)); // let subscriptions register
// --- QA reports a bug, a UI change against Figma, and the backend dependency ---
const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key version } }`;
const bug = (
await gql('token-qa', create, {
i: {
projectId,
type: 'BUG',
title: 'Pay button does nothing on Safari',
area: 'FRONTEND',
priority: 'CRITICAL',
assigneeId: '102',
labels: ['Safari', 'checkout'],
},
})
).createIssue;
const ui = (
await gql('token-qa', create, {
i: {
projectId,
type: 'UI_CHANGE',
title: 'Summary card spacing differs from Figma',
area: 'FRONTEND',
figmaUrl: 'https://www.figma.com/design/abc/Checkout',
},
})
).createIssue;
const api = (
await gql('token-ana', create, {
i: {
projectId,
type: 'FEATURE',
title: 'Expose /payments/intent',
area: 'BACKEND',
},
})
).createIssue;
expect([bug.key, ui.key, api.key]).toEqual([
`${key}-1`,
`${key}-2`,
`${key}-3`,
]);
// --- Relations FE ↔ BE ---
await gql(
'token-bob',
`mutation($i: LinkIssuesInput!) { linkIssues(input: $i) { version } }`,
{
i: { issueId: bug.id, type: 'DEPENDS_ON', targetIssueId: api.id },
},
);
const backend = await eventually(
() =>
gql(
'token-bob',
`query($k: String!) { issue(idOrKey: $k) { links { type direction issueKey } } }`,
{ k: api.key },
),
(d) => d.issue.links.length === 1,
);
expect(backend.issue.links[0]).toEqual({
type: 'DEPENDS_ON',
direction: 'INWARD',
issueKey: bug.key,
});
// --- Workflow including a failed QA round ---
const move = (token: string, status: string, reason?: string) =>
gql(
token,
`mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`,
{
i: { issueId: bug.id, status, reason },
},
);
await move('token-bob', 'IN_PROGRESS');
await move('token-bob', 'READY_FOR_QA');
await move('token-qa', 'IN_QA');
await expect(move('token-qa', 'QA_FAILED')).rejects.toThrow(
/reason is required/,
);
await move('token-qa', 'QA_FAILED', 'Still failing on Safari 17');
await expect(move('token-bob', 'DONE')).rejects.toThrow(/Cannot move/);
// --- Comments ---
await gql(
'token-bob',
`mutation($i: ID!, $b: String!) { addComment(issueId: $i, body: $b) { id } }`,
{
i: bug.id,
b: 'Root cause: Safari blocks the payment popup. Decision: use redirect flow.',
},
);
// --- Releases ---
const release = (
await gql(
'token-ana',
`mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`,
{
i: { projectId, name: 'v2.0.0' },
},
)
).createRelease;
await eventually(
() =>
gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, {
id: release.id,
}),
(d) => !!d.release,
);
await gql(
'token-ana',
`mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`,
{ i: bug.id, r: release.id },
);
await expect(
gql(
'token-ana',
`mutation($r: ID!) { publishRelease(releaseId: $r) { version } }`,
{ r: release.id },
),
).rejects.toThrow(/unfinished/);
// --- Read model (MongoDB) converged ---
const view = await eventually(
() =>
gql(
'token-bob',
`query($k: String!) {
issue(idOrKey: $k) {
key status priority labels commentCount allowedTransitions
assignee { name } reporter { name } release { name }
links { type direction issueKey } linkedIssues { key }
comments { total items { body author { name } } }
history { summary }
}
}`,
{ k: bug.key },
),
(d) => d.issue.commentCount === 1 && d.issue.release?.name === 'v2.0.0',
);
expect(view.issue).toMatchObject({
status: 'QA_FAILED',
priority: 'CRITICAL',
labels: ['safari', 'checkout'],
assignee: { name: 'Bob (frontend)' },
reporter: { name: 'Quinn (QA)' },
allowedTransitions: ['IN_PROGRESS', 'TODO'],
links: [{ type: 'DEPENDS_ON', direction: 'OUTWARD', issueKey: api.key }],
linkedIssues: [{ key: api.key }],
comments: { total: 1, items: [{ author: { name: 'Bob (frontend)' } }] },
});
expect(
view.issue.history.map((h: { summary: string }) => h.summary),
).toContain(
`Quinn (QA) movió ${bug.key} de En QA a QA fallido: Still failing on Safari 17`,
);
const { project } = await gql(
'token-ana',
`query($k: String!) {
project(idOrKey: $k) {
members { user { name } role }
stats { total open byStatus { key count } byAssignee { user { name } count } }
board { status total }
releases { name progress { total finished percent } }
}
}`,
{ k: key },
);
expect(project.members).toHaveLength(3);
expect(project.stats).toMatchObject({ total: 3, open: 3 });
expect(
project.board.find((c: { status: string }) => c.status === 'QA_FAILED')
.total,
).toBe(1);
expect(project.releases[0]).toMatchObject({
name: 'v2.0.0',
progress: { total: 1, finished: 0, percent: 0 },
});
// --- Authorisation on the read side ---
await expect(
gql('token-eve', `query($k: String!) { issue(idOrKey: $k) { key } }`, {
k: bug.key,
}),
).rejects.toThrow(/not found/);
// --- Realtime delivered ---
await eventually(
async () => board.received,
(r) => r.some((m) => m.issueChanged.issue.status === 'QA_FAILED'),
);
expect(board.received[0].issueChanged).toMatchObject({
action: 'IssueCreatedEvent',
issue: { key: bug.key },
});
expect(feed.received.map((m) => m.activityAdded.summary)).toContain(
`Bob (frontend) comentó en ${bug.key}`,
);
// Non-members get an error at subscribe time; unauthenticated sockets are closed.
expect(intruder.received).toHaveLength(0);
expect(JSON.stringify(intruder.errors)).toMatch(/not found/);
expect(anonymous.received).toHaveLength(0);
expect(anonymous.errors).toHaveLength(1);
});
});

View File

@@ -0,0 +1,123 @@
// TEMPORARY (UI work): API on :3001 with fake Pireon users and seeded data. Not part of the repo.
import { ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import {
IdentityProvider,
InvalidAccessTokenError,
InvalidCredentialsError,
} from '@task-stream/api/identity/application';
import { User, UserFactory } from '@task-stream/api/identity/domain';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
process.env['POSTGRES_URL'] ??= 'postgres://taskstream:taskstream@localhost:5432/taskstream';
process.env['POSTGRES_SYNCHRONIZE'] = 'true';
process.env['MONGO_URL'] = `mongodb://localhost:27017/taskstream_ui_${Date.now()}`;
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
process.env['EVENT_STREAM_KEY'] = `task-stream:ui:${Date.now()}`;
process.env['GRAPHQL_SCHEMA_FILE'] = '';
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
const { AppModule } = require('../src/app/app.module');
const users = new UserFactory();
const person = (id: string, name: string, email: string) => users.create({ id, name, email, isActive: true });
const people: Record<string, User> = {
'token-ana': person('101', 'Ana García', 'ana@pireon.dev'),
'token-bob': person('102', 'Bob Martín', 'bob@pireon.dev'),
'token-qa': person('103', 'Quinn Ruiz', 'quinn@pireon.dev'),
'token-dan': person('104', 'Dan Ortega', 'dan@pireon.dev'),
};
const byId = (id: string) => Object.values(people).find((p) => p.id === id) ?? null;
const PATH: Record<string, string[]> = {
BACKLOG: [], TODO: ['TODO'], IN_PROGRESS: ['TODO', 'IN_PROGRESS'],
IN_REVIEW: ['TODO', 'IN_PROGRESS', 'IN_REVIEW'],
READY_FOR_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA'],
IN_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA'],
QA_FAILED: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA', 'QA_FAILED'],
DONE: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'], CANCELLED: ['CANCELLED'],
};
describe('ui dev server', () => {
it('serves until stopped', async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(IdentityProvider)
.useValue({
whoAmI: async (token: string) => { const u = people[token]; if (!u) throw new InvalidAccessTokenError(); return u; },
findUser: async (id: string) => byId(id),
listUsers: async () => Object.values(people),
// Email + any password "pireon" → that user's token.
issueToken: async (grant: { type: string; username?: string; password?: string }) => {
const entry = Object.entries(people).find(([, u]) => u.email === grant.username);
if (grant.type !== 'password' || !entry || grant.password !== 'pireon') throw new InvalidCredentialsError('Email o contraseña incorrectos');
return { accessToken: entry[0], refreshToken: null, expiresIn: 86400, tokenType: 'Bearer' };
},
authorizationConfig: () => ({ authorizeUrl: 'https://oauth.pireon.dev/oauth/authorize', clientId: 'ui-dev', scopes: ['users:read'], passwordGrant: true }),
})
.compile();
const app = moduleRef.createNestApplication({ rawBody: true });
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new DomainErrorFilter());
await app.listen(3001);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const gql = async (token: string, query: string, variables?: object): Promise<any> => {
const r = await fetch('http://localhost:3001/graphql', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) });
const body = await r.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
};
const ana = 'token-ana';
const project = async (key: string, name: string, description: string) => (await gql(ana, `mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`, { i: { key, name, description } })).createProject.id as string;
const member = (projectId: string, userId: string, role: string) => gql(ana, `mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`, { i: { projectId, userId, role } });
const release = async (projectId: string, name: string, targetDate: string, description: string) => (await gql(ana, `mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`, { i: { projectId, name, targetDate, description } })).createRelease.id as string;
const move = async (issueId: string, status: string, reason?: string) => {
for (const step of PATH[status]) await gql(ana, `mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`, { i: { issueId, status: step, reason: step === 'QA_FAILED' || step === 'CANCELLED' ? reason : undefined } });
};
const issue = async (projectId: string, i: Record<string, unknown>, status = 'BACKLOG', reason?: string) => {
const id = (await gql(ana, `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id } }`, { i: { projectId, ...i } })).createIssue.id as string;
await move(id, status, reason);
return id;
};
const comment = (token: string, issueId: string, body: string) => gql(token, `mutation($i: ID!, $b: String!) { addComment(issueId: $i, body: $b) { id } }`, { i: issueId, b: body });
const link = (issueId: string, targetIssueId: string, type: string) => gql(ana, `mutation($i: LinkIssuesInput!) { linkIssues(input: $i) { version } }`, { i: { issueId, targetIssueId, type } });
const web = await project('WEB', 'Checkout revamp', 'Rediseño del checkout: pago en un paso, cupones y QA en cada release.');
for (const [id, role] of [['102', 'MEMBER'], ['103', 'MEMBER'], ['104', 'ADMIN']]) await member(web, id, role);
const v132 = await release(web, 'v1.3.2', '2026-09-26', 'Tarjetas guardadas y webhooks v2.');
const v140 = await release(web, 'v1.4.0', '2026-10-17', 'Checkout en un paso.');
await release(web, 'v1.5.0', '2026-11-14', 'Nuevos métodos de pago.');
const pay = await issue(web, { type: 'BUG', priority: 'CRITICAL', area: 'FRONTEND', assigneeId: '102', releaseId: v140, labels: ['safari', 'checkout'], title: 'El botón de pagar no responde en Safari 17', description: '1. Abrir el checkout en Safari 17\n2. Rellenar la tarjeta\n3. Pulsar «Pagar»: no pasa nada y no hay error en consola.' }, 'IN_PROGRESS');
const cart = await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['carrito'], title: 'Guardar el carrito entre dispositivos', description: 'El carrito se asocia al usuario y se fusiona al iniciar sesión.' }, 'IN_REVIEW');
const summary = await issue(web, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'DESIGN', assigneeId: '102', releaseId: v140, figmaUrl: 'https://www.figma.com/design/abc123/Checkout?node-id=12-345', title: 'Nuevo resumen del pedido según Figma' }, 'READY_FOR_QA');
const regression = await issue(web, { type: 'QA', priority: 'HIGH', area: 'QA', assigneeId: '103', releaseId: v140, title: 'Regresión del checkout en iOS y Android' }, 'IN_QA');
const coupon = await issue(web, { type: 'BUG', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['cupones'], title: 'El cupón se aplica dos veces si se recarga la página' }, 'QA_FAILED', 'Sigue duplicándose si se recarga durante el pago.');
await issue(web, { type: 'TASK', priority: 'LOW', area: 'DEVOPS', assigneeId: '104', title: 'Alertas de latencia del servicio de pagos' }, 'TODO');
await issue(web, { type: 'FEATURE', priority: 'MEDIUM', area: 'FRONTEND', title: 'Pago con Bizum' });
await issue(web, { type: 'BUG', priority: 'LOW', area: 'FRONTEND', assigneeId: '102', title: 'El foco se pierde al cerrar el modal de dirección' }, 'TODO');
await issue(web, { type: 'UI_CHANGE', priority: 'LOW', area: 'DESIGN', title: 'Estados vacíos del historial de pedidos' });
await issue(web, { type: 'TASK', priority: 'MEDIUM', area: 'BACKEND', assigneeId: '104', releaseId: v132, title: 'Migrar webhooks de Stripe a la v2' }, 'DONE');
await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'FRONTEND', assigneeId: '102', releaseId: v132, title: 'Guardar tarjeta para próximas compras' }, 'DONE');
await issue(web, { type: 'QA', priority: 'MEDIUM', area: 'QA', assigneeId: '103', title: 'Pruebas exploratorias de devoluciones' }, 'TODO');
await issue(web, { type: 'BUG', priority: 'MEDIUM', area: 'MOBILE', title: 'La app se cierra al girar en el paso de envío' });
await issue(web, { type: 'TASK', priority: 'LOWEST', area: 'DEVOPS', title: 'Limpiar feature flags del checkout antiguo' }, 'CANCELLED', 'Se hará con la limpieza general de flags.');
await gql(ana, `mutation($r: ID!) { publishRelease(releaseId: $r) { version } }`, { r: v132 });
await link(regression, summary, 'TESTS');
await link(summary, cart, 'DEPENDS_ON');
await link(coupon, regression, 'BLOCKS');
await comment('token-bob', pay, 'Reproducido: el `onClick` llega, pero el iframe de pago no termina de cargar en Safari.');
await comment('token-qa', pay, 'En Safari 16 funciona. Adjunto vídeo en el ticket de regresión.');
await comment(ana, pay, 'Decisión: usar redirect en lugar de popup para el 3-D Secure.');
const api = await project('API', 'Plataforma API', 'Servicios compartidos: identidad, pagos y notificaciones.');
await member(api, '104', 'ADMIN');
await issue(api, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', title: 'Rate limiting por cliente' }, 'IN_PROGRESS');
await issue(api, { type: 'BUG', priority: 'MEDIUM', area: 'BACKEND', title: 'Timeout en /payments/refund con importes altos' }, 'TODO');
const mob = await project('MOB', 'App móvil', 'iOS y Android: compra rápida y notificaciones.');
await member(mob, '102', 'MEMBER');
await issue(mob, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'MOBILE', title: 'Nueva navegación inferior' }, 'IN_REVIEW');
console.log('UI DEV API READY on :3001');
await new Promise(() => undefined);
}, 24 * 60 * 60 * 1000);
});

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../eslint.config.mjs';
export default [...baseConfig];

10
apps/api/jest.config.cts Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
displayName: 'api',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
};

View File

@@ -0,0 +1,15 @@
/**
* End-to-end tests against real PostgreSQL, MongoDB and Redis.
* Requires `docker compose up -d`. Run with `npx nx e2e api`.
*/
module.exports = {
displayName: 'api-e2e',
preset: '../../jest.preset.js',
testEnvironment: 'node',
testMatch: ['<rootDir>/e2e/**/*.e2e-spec.ts'],
testTimeout: 30000,
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
};

34
apps/api/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "api",
"version": "0.0.1",
"private": true,
"description": "Runtime dependencies of the API image. Nx uses this list for dist/apps/api/package.json (some packages, such as pg or @as-integrations/express5, are loaded dynamically and invisible to its import analysis).",
"dependencies": {
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@nestjs/apollo": "^13.4.5",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.0",
"@nestjs/cqrs": "^11.0.3",
"@nestjs/graphql": "^13.4.5",
"@nestjs/mongoose": "^11.0.4",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/typeorm": "^11.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"express": "5.2.1",
"graphql": "^16.14.2",
"graphql-redis-subscriptions": "^2.7.0",
"graphql-ws": "^6.3.0",
"ioredis": "^5.11.1",
"joi": "^18.2.9",
"mongoose": "^8.24.4",
"pg": "^8.23.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"tslib": "^2.8.1",
"typeorm": "^0.3.31",
"ws": "^8.22.0"
}
}

78
apps/api/project.json Normal file
View File

@@ -0,0 +1,78 @@
{
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src",
"projectType": "application",
"tags": [
"scope:api",
"type:app"
],
"targets": {
"prune-lockfile": {
"dependsOn": [
"build"
],
"cache": true,
"executor": "@nx/js:prune-lockfile",
"outputs": [
"{workspaceRoot}/dist/apps/api/package.json",
"{workspaceRoot}/dist/apps/api/package-lock.json"
],
"options": {
"buildTarget": "build"
}
},
"copy-workspace-modules": {
"dependsOn": [
"build"
],
"cache": true,
"outputs": [
"{workspaceRoot}/dist/apps/api/workspace_modules"
],
"executor": "@nx/js:copy-workspace-modules",
"options": {
"buildTarget": "build"
}
},
"prune": {
"dependsOn": [
"prune-lockfile",
"copy-workspace-modules"
],
"executor": "nx:noop"
},
"serve": {
"continuous": true,
"executor": "@nx/js:node",
"defaultConfiguration": "development",
"dependsOn": [
"build"
],
"options": {
"buildTarget": "api:build",
"runBuildTargetDependencies": false
},
"configurations": {
"development": {
"buildTarget": "api:build:development"
},
"production": {
"buildTarget": "api:build:production"
}
}
},
"test": {
"options": {
"passWithNoTests": true
}
},
"e2e": {
"executor": "nx:run-commands",
"options": {
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
},
"cache": false
}
}
}

562
apps/api/schema.gql Normal file
View File

@@ -0,0 +1,562 @@
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
type Activity {
actor: UserRef!
id: ID!
issueId: ID
issueKey: String
occurredAt: String!
projectId: ID!
summary: String!
type: String!
}
type BoardColumn {
issues: [Issue!]!
status: IssueStatus!
total: Int!
}
input ChangeIssueStatusInput {
issueId: ID!
"""
Required for QA_FAILED and CANCELLED
"""
reason: String
status: IssueStatus!
}
"""
Writes are acknowledged immediately; the read model catches up asynchronously. Wait for a subscription event with version >= this version to see the change.
"""
type CommandResult {
id: ID!
key: String
version: Int!
}
type Comment {
author: UserRef!
body: String!
createdAt: String!
editedAt: String
id: ID!
issueId: ID!
version: Int!
}
type CommentChange {
action: String!
actorId: ID!
"""
Null when deleted
"""
comment: Comment
commentId: ID!
issueId: ID!
version: Int!
}
type CommentPage {
items: [Comment!]!
total: Int!
}
type CountByAssignee {
count: Int!
"""
Null = unassigned
"""
user: UserRef
}
type CountByKey {
count: Int!
key: String!
}
type CountByStatus {
count: Int!
key: IssueStatus!
}
input CreateIssueInput {
area: IssueArea
assigneeId: ID
description: String
dueDate: String
"""
Figma frame for UI changes
"""
figmaUrl: String
labels: [String!]
priority: IssuePriority
projectId: ID!
releaseId: ID
storyPoints: Int
title: String!
type: IssueType!
}
input CreateProjectInput {
description: String
"""
2-10 chars, e.g. "TS" → tickets TS-1, TS-2...
"""
key: String!
name: String!
}
input CreateReleaseInput {
description: String
"""
e.g. v1.4.0
"""
name: String!
projectId: ID!
targetDate: String
}
type Issue {
"""
Statuses this ticket can move to next
"""
allowedTransitions: [IssueStatus!]!
area: IssueArea
assignee: UserRef
commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
createdAt: String!
description: String
dueDate: String
figmaUrl: String
"""
History of this ticket
"""
history(limit: Int! = 50): [Activity!]!
id: ID!
"""
Human key, e.g. TS-42
"""
key: String!
labels: [String!]!
"""
Linked tickets I can see
"""
linkedIssues: [Issue!]!
links: [IssueLink!]!
number: Int!
priority: IssuePriority!
projectId: ID!
projectKey: String!
release: ReleaseRef
reporter: UserRef!
status: IssueStatus!
storyPoints: Int
title: String!
type: IssueType!
updatedAt: String!
version: Int!
}
"""
Team/layer: links FE ↔ BE ↔ QA work
"""
enum IssueArea {
BACKEND
DESIGN
DEVOPS
FRONTEND
MOBILE
QA
}
type IssueChange {
action: String!
actorId: ID!
issue: Issue!
version: Int!
}
input IssueFilterInput {
areas: [IssueArea!]
assigneeId: ID
labels: [String!]
priorities: [IssuePriority!]
projectId: ID!
releaseId: ID
"""
Matches key, title or description
"""
search: String
statuses: [IssueStatus!]
types: [IssueType!]
unassigned: Boolean
}
type IssueLink {
"""
OUTWARD: this issue → other. INWARD: other → this issue.
"""
direction: LinkDirection!
issueId: ID!
issueKey: String!
type: IssueLinkType!
}
enum IssueLinkType {
BLOCKS
DEPENDS_ON
DUPLICATES
RELATES_TO
TESTS
}
type IssuePage {
items: [Issue!]!
total: Int!
}
enum IssuePriority {
CRITICAL
HIGH
LOW
LOWEST
MEDIUM
}
enum IssueStatus {
BACKLOG
CANCELLED
DONE
IN_PROGRESS
IN_QA
IN_REVIEW
QA_FAILED
READY_FOR_QA
TODO
}
enum IssueType {
BUG
FEATURE
QA
TASK
UI_CHANGE
}
enum LinkDirection {
INWARD
OUTWARD
}
input LinkIssuesInput {
issueId: ID!
targetIssueId: ID!
type: IssueLinkType!
}
type Me {
"""
Roles inside the task-stream OAuth client
"""
appRoles: [String!]!
email: String!
id: ID!
isActive: Boolean!
name: String!
"""
Global roles in Pireon
"""
roles: [String!]!
}
type Mutation {
addComment(body: String!, issueId: ID!): CommandResult!
"""
Add a Pireon user to the project
"""
addProjectMember(input: ProjectMemberInput!): CommandResult!
archiveRelease(releaseId: ID!): CommandResult!
"""
Assign (or unassign with null) a project member
"""
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
"""
Move a ticket through the workflow (incl. QA)
"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult!
"""
Author or project admin
"""
deleteComment(commentId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""
Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE
"""
linkIssues(input: LinkIssuesInput!): CommandResult!
"""
Ship the release. Fails while it has unfinished tickets unless force = true.
"""
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""
Put a ticket in a release (null removes it)
"""
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
updateIssue(input: UpdateIssueInput!): CommandResult!
updateProject(input: UpdateProjectInput!): CommandResult!
updateRelease(input: UpdateReleaseInput!): CommandResult!
}
type Project {
"""
Kanban board, one column per status
"""
board(
area: IssueArea
assigneeId: ID
perColumn: Int! = 50
releaseId: ID
): [BoardColumn!]!
createdAt: String!
description: String
id: ID!
key: String!
members: [ProjectMember!]!
name: String!
owner: UserRef!
releases: [Release!]!
"""
Project tracking dashboard
"""
stats: ProjectStats!
updatedAt: String!
version: Int!
}
type ProjectChange {
action: String!
actorId: ID!
project: Project!
version: Int!
}
type ProjectMember {
role: ProjectRole!
user: UserRef!
}
input ProjectMemberInput {
projectId: ID!
role: ProjectRole! = MEMBER
"""
Pireon user id
"""
userId: ID!
}
enum ProjectRole {
ADMIN
MEMBER
OWNER
VIEWER
}
type ProjectStats {
byArea: [CountByKey!]!
"""
Open tickets per person
"""
byAssignee: [CountByAssignee!]!
byPriority: [CountByKey!]!
byStatus: [CountByStatus!]!
byType: [CountByKey!]!
open: Int!
total: Int!
}
type Query {
"""
Project activity feed (newest first)
"""
activity(
"""
ISO date cursor
"""
before: String
issueId: ID
limit: Int! = 50
projectId: ID!
): [Activity!]!
issue(
"""
Issue id or key (e.g. "TS-42")
"""
idOrKey: String!
): Issue!
issues(
filter: IssueFilterInput!
limit: Int! = 50
offset: Int! = 0
): IssuePage!
"""
The authenticated user
"""
me: Me!
"""
Projects where I am a member
"""
myProjects: [Project!]!
project(
"""
Project id or key (e.g. "TS")
"""
idOrKey: String!
): Project!
release(id: ID!): Release!
user(id: String!): User
"""
Search users by name or email (to assign / add members)
"""
users(limit: Int! = 20, search: String! = ""): [User!]!
}
type Release {
createdAt: String!
description: String
id: ID!
"""
Tickets included in this release
"""
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
name: String!
progress: ReleaseProgress!
projectId: ID!
releasedAt: String
status: ReleaseStatus!
targetDate: String
version: Int!
}
type ReleaseChange {
action: String!
actorId: ID!
release: Release!
version: Int!
}
type ReleaseProgress {
byStatus: [CountByStatus!]!
finished: Int!
percent: Int!
total: Int!
}
type ReleaseRef {
id: ID!
name: String!
}
enum ReleaseStatus {
ARCHIVED
PLANNED
RELEASED
}
type Subscription {
"""
Live activity feed
"""
activityAdded(projectId: ID!): Activity!
commentChanged(issueId: ID!): CommentChange!
"""
Tickets created/updated/moved in a project (board in realtime)
"""
issueChanged(projectId: ID!): IssueChange!
"""
Projects I am added to / removed from
"""
myProjectsChanged: ProjectChange!
projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange!
}
input UpdateIssueInput {
area: IssueArea
description: String
dueDate: String
"""
Figma frame for UI changes
"""
figmaUrl: String
issueId: ID!
labels: [String!]
priority: IssuePriority
storyPoints: Int
title: String
type: IssueType
}
input UpdateProjectInput {
description: String
name: String
projectId: ID!
}
input UpdateReleaseInput {
description: String
name: String
releaseId: ID!
targetDate: String
}
"""
User managed by the Pireon identity server
"""
type User {
email: String!
id: ID!
isActive: Boolean!
name: String!
}
type UserRef {
id: ID!
name: String!
}

View File

@@ -0,0 +1,130 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { MongooseModule } from '@nestjs/mongoose';
import { TypeOrmModule } from '@nestjs/typeorm';
import { IdentityApplicationModule } from '@task-stream/api/identity/application';
import { IdentityInfrastructureModule } from '@task-stream/api/identity/infrastructure';
import {
AuthRequest,
IdentityPresentersModule,
WsAuthenticator,
} from '@task-stream/api/identity/presenters';
import { SharedModule } from '@task-stream/api/shared/application';
import { SharedInfrastructureModule } from '@task-stream/api/shared/infrastructure';
import { TrackerApplicationModule } from '@task-stream/api/tracker/application';
import { TrackerInfrastructureModule } from '@task-stream/api/tracker/infrastructure';
import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters';
import { join } from 'node:path';
import { configSchema } from './config.schema';
import { HealthController } from './health.controller';
/*
* Composition root. Each bounded context is assembled outside-in:
* presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters)
*/
const identity = IdentityPresentersModule.withApplication(
IdentityApplicationModule.withInfrastructure(IdentityInfrastructureModule),
);
const tracker = TrackerPresentersModule.withApplication(
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule),
);
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: configSchema,
cache: true,
}),
// Write model: PostgreSQL (event store + lookup tables)
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
url: config.getOrThrow<string>('POSTGRES_URL'),
autoLoadEntities: true,
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'),
}),
}),
// Read model: MongoDB
MongooseModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.getOrThrow<string>('MONGO_URL'),
}),
}),
// Event store (PostgreSQL) → Redis stream → events bridge, AggregateRehydrator
SharedModule.withInfrastructure(
SharedInfrastructureModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
redisUrl: config.getOrThrow<string>('REDIS_URL'),
streamKey: config.getOrThrow<string>('EVENT_STREAM_KEY'),
maxLength: config.getOrThrow<number>('EVENT_STREAM_MAX_LENGTH'),
eventsBridgeEnabled: config.getOrThrow<boolean>(
'EVENTS_BRIDGE_ENABLED',
),
}),
}),
),
// GraphQL + realtime subscriptions over graphql-ws
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [identity],
inject: [WsAuthenticator, ConfigService],
useFactory: (ws: WsAuthenticator, config: ConfigService) => ({
// Writes the SDL for frontend codegen; empty = keep the schema in memory.
autoSchemaFile: config.get<string>('GRAPHQL_SCHEMA_FILE')
? join(
process.cwd(),
config.get<string>('GRAPHQL_SCHEMA_FILE') as string,
)
: true,
sortSchema: true,
// GraphiQL on GET /graphql (send the Pireon token in the Headers tab).
graphiql: config.get<boolean>('GRAPHQL_PLAYGROUND')
? { url: '/graphql' }
: false,
playground: false,
includeStacktraceInErrorResponses:
config.get('NODE_ENV') === 'development',
introspection: config.get<boolean>('GRAPHQL_PLAYGROUND'),
subscriptions: {
'graphql-ws': {
path: '/graphql',
// connection_init payload: { "authorization": "Bearer <pireon token>" }
onConnect: async (ctx) => {
try {
(ctx.extra as { auth?: AuthRequest }).auth =
await ws.authenticate(ctx.connectionParams);
return true;
} catch {
return false; // closes the socket with 4403 Forbidden
}
},
},
},
context: ({
req,
extra,
}: {
req?: AuthRequest;
extra?: { auth?: AuthRequest };
}) => ({
req: extra?.auth ?? req,
}),
}),
}),
identity,
tracker,
],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -0,0 +1,36 @@
import * as Joi from 'joi';
export const configSchema = Joi.object({
PORT: Joi.number().default(3000),
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
// Write model
POSTGRES_URL: Joi.string().uri().required(),
POSTGRES_SYNCHRONIZE: Joi.boolean().default(false),
// Read model
MONGO_URL: Joi.string().uri().required(),
// Event stream + GraphQL pub/sub + session cache
REDIS_URL: Joi.string().uri().required(),
EVENT_STREAM_KEY: Joi.string().default('task-stream:events'),
EVENT_STREAM_MAX_LENGTH: Joi.number().default(1_000_000),
// Consume the Redis stream and run @EventsHandler projections on this instance.
EVENTS_BRIDGE_ENABLED: Joi.boolean().default(true),
// Pireon identity server
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// OAuth client of task-stream in Pireon, used by the web app to sign in.
PIREON_CLIENT_ID: Joi.string().allow('').optional(),
PIREON_CLIENT_SECRET: Joi.string().allow('').optional(),
PIREON_SCOPES: Joi.string().default('users:read'),
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
PIREON_TIMEOUT_MS: Joi.number().default(5000),
AUTH_SESSION_TTL_SECONDS: Joi.number().min(0).default(60),
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
});

View File

@@ -0,0 +1,39 @@
import {
Controller,
Get,
Inject,
ServiceUnavailableException,
} from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Public } from '@task-stream/api/identity/presenters';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
import Redis from 'ioredis';
import { Connection } from 'mongoose';
import { DataSource } from 'typeorm';
@Controller('health')
export class HealthController {
constructor(
private readonly postgres: DataSource,
@InjectConnection() private readonly mongo: Connection,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
@Public()
@Get()
async check() {
const checks = await Promise.allSettled([
this.postgres.query('SELECT 1'),
this.mongo.db?.admin().ping(),
this.redis.ping(),
]);
const [postgres, mongo, redis] = checks.map((c) =>
c.status === 'fulfilled' ? 'up' : 'down',
);
const result = { status: 'ok', postgres, mongo, redis };
if (checks.some((c) => c.status === 'rejected')) {
throw new ServiceUnavailableException({ ...result, status: 'degraded' });
}
return result;
}
}

View File

23
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
import { AppModule } from './app/app.module';
async function bootstrap() {
// rawBody: needed to verify Pireon webhook signatures.
const app = await NestFactory.create(AppModule, { rawBody: true });
app.setGlobalPrefix('api');
app.enableCors();
app.enableShutdownHooks();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new DomainErrorFilter());
const port = process.env['PORT'] || 3000;
await app.listen(port);
Logger.log(
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`,
);
Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
}
bootstrap();

View File

@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "es2021",
"moduleResolution": "bundler"
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

16
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"esModuleInterop": true
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "bundler",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts",
"e2e/**/*.ts"
]
}

View File

@@ -0,0 +1,25 @@
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { join } = require('path');
module.exports = {
output: {
path: join(__dirname, '../../dist/apps/api'),
clean: true,
...(process.env.NODE_ENV !== 'production' && {
devtoolModuleFilenameTemplate: '[absolute-resource-path]',
}),
},
plugins: [
new NxAppWebpackPlugin({
target: 'node',
compiler: 'tsc',
main: './src/main.ts',
tsConfig: './tsconfig.app.json',
assets: ['./src/assets'],
optimization: false,
outputHashing: 'none',
generatePackageJson: true,
sourceMap: true,
}),
],
};

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ts',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ts',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

38
apps/web/nginx.conf Normal file
View File

@@ -0,0 +1,38 @@
# Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /graphql {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
# Subscriptions keep the socket open.
proxy_read_timeout 1h;
}
# Hashed bundles can be cached forever; index.html never.
location ~* \.(js|css|woff2?|svg|png|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}

97
apps/web/project.json Normal file
View File

@@ -0,0 +1,97 @@
{
"name": "web",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "ts",
"sourceRoot": "apps/web/src",
"tags": ["scope:web", "type:app"],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/web",
"browser": "apps/web/src/main.ts",
"tsConfig": "apps/web/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "apps/web/public"
}
],
"styles": ["apps/web/src/styles.scss"],
"stylePreprocessorOptions": {
"includePaths": ["node_modules"]
},
"allowedCommonJsDependencies": ["lodash"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "3mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "8kb",
"maximumError": "16kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"options": {
"port": 4200,
"proxyConfig": "apps/web/proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "web:build:production"
},
"development": {
"buildTarget": "web:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "web:build",
"port": 4200,
"staticFilePath": "dist/apps/web/browser",
"spa": true
}
},
"codegen": {
"executor": "nx:run-commands",
"options": {
"command": "graphql-codegen --config codegen.ts"
},
"cache": false
}
}
}

4
apps/web/proxy.conf.json Normal file
View File

@@ -0,0 +1,4 @@
{
"/api": { "target": "http://localhost:3000", "secure": false },
"/graphql": { "target": "http://localhost:3000", "secure": false, "ws": true }
}

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

BIN
apps/web/public/logo-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
apps/web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,47 @@
import { provideHttpClient, withFetch } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import {
provideRouter,
withComponentInputBinding,
withRouterConfig,
} from '@angular/router';
import { provideIdentityInfrastructure } from '@task-stream/web/identity/infrastructure';
import {
apiConfigFromLocation,
provideApiConfig,
provideGraphql,
} from '@task-stream/web/shared/infrastructure';
import { providePiUi, ToastService } from '@task-stream/web/shared/ui';
import { Notifications } from '@task-stream/web/tracker/application';
import { provideTrackerInfrastructure } from '@task-stream/web/tracker/infrastructure';
import { appRoutes } from './app.routes';
/*
* Composition root of the web client:
* presenters (routes/pages) → application (NgRx signal stores, events) → domain ports ← infrastructure (GraphQL/HTTP)
*/
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
appRoutes,
// Route params / query params straight into component inputs (`key`, `issueKey`, `returnUrl`…).
withComponentInputBinding(),
withRouterConfig({ paramsInheritanceStrategy: 'always' }),
),
provideHttpClient(withFetch()),
providePiUi(),
// API: same origin, proxied in development (proxy.conf.json) and by nginx in Docker.
provideApiConfig(apiConfigFromLocation()),
provideGraphql(),
// Ports → adapters
provideIdentityInfrastructure(),
provideTrackerInfrastructure(),
{ provide: Notifications, useExisting: ToastService },
],
};

View File

@@ -0,0 +1,26 @@
import { authGuard } from '@task-stream/web/identity/presenters';
import { appRoutes } from './app.routes';
describe('appRoutes', () => {
it('keeps sign-in public and everything else behind the auth guard', () => {
const paths = appRoutes.map((route) => route.path);
expect(paths).toEqual(expect.arrayContaining(['login', 'auth/callback', '', '**']));
const app = appRoutes.find((route) => route.path === '' && route.children);
expect(app?.canActivate).toContain(authGuard);
expect(app?.children?.map((route) => route.path)).toEqual(['', 'p/:key']);
});
it('exposes every project section', () => {
const project = appRoutes.find((route) => route.path === '')?.children?.find((route) => route.path === 'p/:key');
expect(project?.children?.map((route) => route.path)).toEqual([
'',
'board',
'issues/:issueKey',
'releases',
'dashboard',
'activity',
'members',
]);
});
});

View File

@@ -0,0 +1,12 @@
import { Route } from '@angular/router';
import {
authGuard,
identityRoutes,
} from '@task-stream/web/identity/presenters';
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
export const appRoutes: Route[] = [
...identityRoutes,
{ path: '', canActivate: [authGuard], children: trackerRoutes },
{ path: '**', redirectTo: '' },
];

39
apps/web/src/app/app.ts Normal file
View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AuthFacade } from '@task-stream/web/identity/application';
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
import { AppShellComponent } from '@task-stream/web/shared/ui';
@Component({
selector: 'ts-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
AppShellComponent,
UserMenuComponent,
],
template: `
<ts-app-shell>
@if (auth.isAuthenticated()) {
<a
shellNav
class="ts-nav-link"
routerLink="/"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
>
Proyectos
</a>
}
@if (auth.isAuthenticated()) {
<ts-user-menu shellUser />
}
<router-outlet />
</ts-app-shell>
`,
})
export class App {
protected readonly auth = inject(AuthFacade);
}

39
apps/web/src/index.html Normal file
View File

@@ -0,0 +1,39 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>TaskStream</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Gestión de proyectos y tickets en tiempo real"
/>
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="icon" type="image/png" sizes="96x96" href="logo-96.png" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Inter, like pireon.dev · Geist Mono only for data (ticket keys, counters, versions) -->
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400..800&family=Geist+Mono:wght@400..600&display=swap"
rel="stylesheet"
/>
<meta name="theme-color" content="#f7f7fa" />
<script>
// Dark mode before first paint (same key as AppearanceService).
(function () {
try {
var stored = localStorage.getItem('task-stream.appearance');
var dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
if (dark) document.documentElement.classList.add('dark-mode');
} catch (e) {}
})();
</script>
</head>
<body class="pi-shell pi-shell__default">
<ts-root></ts-root>
</body>
</html>

5
apps/web/src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

551
apps/web/src/styles.scss Normal file
View File

@@ -0,0 +1,551 @@
// Global stylesheet — same approach as landing-web.
//
// `@pi/ui/styles/common/index.scss` is deliberately NOT used: its tail calls
// `mat.define-palette()` / `mat.define-light-theme()`, removed from Angular
// Material in v19. Its parts compile, so they are pulled one by one and the
// Material theme is declared here with the current API.
@use '@angular/material' as mat;
@use '@pi/ui/styles/common/abstracts' as *;
@use '@pi/ui/styles/common/base' as *;
@use '@pi/ui/styles/common/components' as *;
@use '@pi/ui/styles/common/layouts' as *;
@use '@pi/ui/styles/common/vendors' as *;
@use '@pi/ui/styles/common/wrappers' as *;
@use '@pi/ui/styles/themes' as *;
// One ramp from a single hue against a surface (the library's components pick
// from anywhere along 10 → 900, so a brand override must cover all of it).
@mixin brand-ramp($name, $hue, $surface) {
--pi-color-#{$name}-10: #{color-mix(in srgb, $hue 5%, $surface)};
--pi-color-#{$name}-25: #{color-mix(in srgb, $hue 9%, $surface)};
--pi-color-#{$name}-50: #{color-mix(in srgb, $hue 13%, $surface)};
--pi-color-#{$name}-75: #{color-mix(in srgb, $hue 17%, $surface)};
--pi-color-#{$name}-100: #{color-mix(in srgb, $hue 23%, $surface)};
--pi-color-#{$name}-200: #{color-mix(in srgb, $hue 55%, $surface)};
--pi-color-#{$name}-300: #{color-mix(in srgb, $hue 75%, $surface)};
--pi-color-#{$name}-400: #{$hue};
--pi-color-#{$name}: #{$hue};
--pi-color-#{$name}-900: #{color-mix(in srgb, $hue 72%, black)};
--pi-color-#{$name}-200-rgba: #{rgba($hue, 0.18)};
}
@mixin dark-ramp($name, $hue) {
--pi-color-#{$name}-10: #{color-mix(in srgb, $hue 5%, #161922)};
--pi-color-#{$name}-25: #{color-mix(in srgb, $hue 9%, #161922)};
--pi-color-#{$name}-50: #{color-mix(in srgb, $hue 13%, #161922)};
--pi-color-#{$name}-75: #{color-mix(in srgb, $hue 17%, #161922)};
--pi-color-#{$name}-100: #{color-mix(in srgb, $hue 23%, #161922)};
--pi-color-#{$name}-200: #{color-mix(in srgb, $hue 55%, #161922)};
--pi-color-#{$name}-300: #{color-mix(in srgb, $hue 75%, #161922)};
--pi-color-#{$name}-400: #{$hue};
--pi-color-#{$name}: #{$hue};
--pi-color-#{$name}-900: #{color-mix(in srgb, $hue 70%, #ffffff)};
--pi-color-#{$name}-200-rgba: #{rgba($hue, 0.18)};
}
html {
@include mat.theme(
(
color: (
primary: mat.$violet-palette,
tertiary: mat.$cyan-palette,
),
typography: Inter,
density: -1,
)
);
}
// `pi-action-button size="w100"`: the library resets the button with `all: unset`
// (content-box) and adds horizontal padding, so 100% + padding overflowed its container.
// Three classes to beat the component's own `[_nghost] > button[_ngcontent]` rule.
.pi-button.pi-button__action.pi-button__w100 {
box-sizing: border-box;
}
// ---------------------------------------------------------------------------
// Design tokens — the Pireon platform look (same palette, type and surfaces as
// pireon.dev, see landing-web), plus one colour per status and per ticket type,
// which carry meaning everywhere (board, cards, charts, history).
// ---------------------------------------------------------------------------
.pi-shell {
// Pireon brand: the logo's two hues (276° violet, 205° blue), darkened to clear 4.5:1.
@include brand-ramp('primary', #a347e1, #ffffff);
@include brand-ramp('accent', #1879bf, #ffffff);
@include brand-ramp('highlighted', #6c2fb5, #ffffff);
--ts-font-display: Inter, system-ui, -apple-system, sans-serif;
--ts-font-body: Inter, system-ui, -apple-system, sans-serif;
--ts-font-mono:
'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--ts-bg: #f7f7fa;
--ts-surface: #ffffff;
--ts-surface-raised: #ffffff;
--ts-sunken: #f0f0f5;
--ts-line: #e4e4ea;
--ts-line-strong: #d3d3dc;
--ts-ink: #17171f;
--ts-ink-soft: #44445a;
--ts-muted: #5b5b6b;
--ts-brand: var(--pi-color-primary);
--ts-accent: var(--pi-color-accent);
--ts-radius: var(--pi-theme-card-radius, 1.5rem);
--ts-shadow: 0 1px 2px rgb(23 23 31 / 4%);
--ts-lift: 0 18px 40px -18px
color-mix(in srgb, var(--pi-color-primary) 45%, transparent);
// Workflow
--ts-s-backlog: #8b8b9a;
--ts-s-todo: #64748b;
--ts-s-progress: #a347e1;
--ts-s-review: #1879bf;
--ts-s-ready: #b7791f;
--ts-s-qa: #ea580c;
--ts-s-failed: #dc2626;
--ts-s-done: #15803d;
--ts-s-cancelled: #a8a8b3;
// Ticket types
--ts-t-bug: #e5484d;
--ts-t-feature: #a347e1;
--ts-t-ui: #d6409f;
--ts-t-task: #64748b;
--ts-t-qa: #d97706;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
font-family: var(--ts-font-body);
background-color: var(--ts-bg);
color: var(--ts-ink);
-webkit-font-smoothing: antialiased;
}
a {
color: var(--ts-brand);
}
h1,
h2,
h3 {
font-family: var(--ts-font-display);
letter-spacing: -0.02em;
color: var(--ts-ink);
}
/* The accent words of a title, in the logo's gradient (as on pireon.dev). */
.ts-gradient-text {
background: linear-gradient(
100deg,
var(--pi-color-primary),
var(--pi-color-accent)
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
::selection {
background: color-mix(in srgb, var(--ts-brand) 25%, transparent);
}
// ---------------------------------------------------------------------------
// App primitives shared by every page.
// ---------------------------------------------------------------------------
.ts-muted {
color: var(--ts-muted);
font-size: 0.875rem;
}
/* Data: keys, counters, dates, versions. */
.ts-mono {
font-family: var(--ts-font-mono);
font-feature-settings: 'zero';
letter-spacing: -0.01em;
}
/* Small uppercase line above a title — pireon.dev's `landing-eyebrow`. */
.ts-eyebrow {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ts-accent);
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
/* Ticket / project key: WEB-12. */
.ts-key {
display: inline-flex;
align-items: center;
padding: 0.0625rem 0.375rem;
border-radius: 0.3125rem;
font-family: var(--ts-font-mono);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0;
color: var(--ts-ink-soft);
background: var(--ts-sunken);
border: 1px solid var(--ts-line);
white-space: nowrap;
}
/* Status / type pill coloured by --tone (set inline from copy.ts). */
.ts-pill {
--tone: var(--ts-muted);
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.1875rem 0.5rem 0.1875rem 0.4375rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
color: color-mix(in srgb, var(--tone) 82%, var(--ts-ink));
background: color-mix(in srgb, var(--tone) 12%, var(--ts-surface));
border: 1px solid color-mix(in srgb, var(--tone) 28%, transparent);
&::before {
content: '';
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--tone);
}
}
/*
* Round icon-only button drawn by the app. `@pi/ui` styles every <button>
* globally (min-width 5.5rem, side padding, radius, no border) with selectors
* too specific to override otherwise, hence the !important.
*/
.ts-icon-button {
display: inline-grid !important;
place-items: center;
width: var(--ts-icon-button-size, 2rem);
height: var(--ts-icon-button-size, 2rem);
min-width: 0 !important;
padding: 0 !important;
border: 1px solid var(--ts-icon-button-border, transparent) !important;
border-radius: 999px !important;
background: transparent;
color: inherit;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--ts-brand);
outline-offset: 2px;
}
}
/*
* Pireon's card (pireon.dev's `landing-card`): a faint violet→blue wash and a
* gradient edge drawn with mask-composite. `.ts-panel` is the static version,
* `.ts-card-link` adds the hover lift for cards that navigate.
*/
.ts-panel,
.ts-card-link {
position: relative;
isolation: isolate;
border-radius: var(--ts-radius);
background-image: linear-gradient(
155deg,
color-mix(in srgb, var(--pi-color-primary) 7%, var(--ts-surface)) 0%,
var(--ts-surface) 45%,
color-mix(in srgb, var(--pi-color-accent) 5%, var(--ts-surface)) 100%
);
box-shadow: var(--ts-shadow);
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
150deg,
color-mix(in srgb, var(--pi-color-primary) 55%, transparent) 0%,
color-mix(in srgb, var(--pi-color-accent) 35%, transparent) 55%,
var(--ts-line) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
}
.ts-card-link {
display: block;
color: inherit;
text-decoration: none;
transition:
transform 0.22s ease-out,
box-shadow 0.22s ease-out;
&:hover {
transform: translateY(-3px);
box-shadow: var(--ts-lift);
}
&:focus-visible {
outline: 2px solid var(--ts-brand);
outline-offset: 3px;
}
}
.ts-prewrap {
white-space: pre-wrap;
}
.ts-center {
display: grid;
place-items: center;
min-height: 40vh;
}
.ts-plain-link {
color: inherit;
text-decoration: none;
}
.ts-page-header,
.ts-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
h1 {
margin: 0;
font-size: clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem);
font-weight: 750;
line-height: 1.05;
}
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
p {
margin: 0.375rem 0 0;
color: var(--ts-muted);
}
}
.ts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 1rem;
}
.ts-form {
display: grid;
gap: 1rem;
}
.ts-form__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
// Project tabs: a ruler with a thick ink marker under the current section.
.ts-tabs {
display: flex;
gap: 0.25rem;
margin: 1.25rem 0 1.75rem;
border-bottom: 1px solid var(--ts-line-strong);
overflow-x: auto;
scrollbar-width: none;
}
.ts-tabs a {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem 0.75rem;
color: var(--ts-muted);
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: color 0.15s ease;
&::after {
content: '';
position: absolute;
inset: auto 0.5rem -1px;
height: 3px;
border-radius: 3px 3px 0 0;
background: transparent;
transition: background-color 0.15s ease;
}
&:hover {
color: var(--ts-ink);
}
&.is-active {
color: var(--ts-brand);
font-weight: 600;
&::after {
background: linear-gradient(
90deg,
var(--pi-color-primary),
var(--pi-color-accent)
);
}
}
}
// Header links, as in pireon.dev's header.
.ts-nav-link {
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
color: var(--ts-muted);
font-size: 0.9375rem;
text-decoration: none;
white-space: nowrap;
&:hover {
color: var(--ts-ink);
}
&.is-active {
color: var(--ts-brand);
font-weight: 600;
background: var(--pi-color-primary-50);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
// `@pi/ui`'s reset gives every `li + li` a top margin, meant for prose.
ul > li,
ol > li {
margin-top: 0;
}
// ---------------------------------------------------------------------------
// Dark mode. `@pi/ui` has no dark styles: `PiThemeService` toggles
// `html.dark-mode` and the library's colour contract is redefined under it
// (on `.pi-shell`, the <body>, where the theme writes its own ramps).
// ---------------------------------------------------------------------------
html.dark-mode {
color-scheme: dark;
}
html.dark-mode .pi-shell {
// Same dark palette as pireon.dev.
--ts-bg: #0f1116;
--ts-surface: #161922;
--ts-surface-raised: #1c202b;
--ts-sunken: #12141a;
--ts-line: #262c3a;
--ts-line-strong: #333a4b;
--ts-ink: #f4f5f9;
--ts-ink-soft: #d8dbe6;
--ts-muted: #a9b0c1;
--ts-shadow: 0 1px 2px rgb(0 0 0 / 30%);
--ts-s-backlog: #8a91a4;
--ts-s-todo: #94a3b8;
--ts-s-progress: #ae5de5;
--ts-s-review: #339be6;
--ts-s-ready: #eab308;
--ts-s-qa: #fb923c;
--ts-s-failed: #f07070;
--ts-s-done: #34c98a;
--ts-s-cancelled: #6b7180;
--ts-t-bug: #f07070;
--ts-t-feature: #ae5de5;
--ts-t-ui: #f472b6;
--ts-t-task: #94a3b8;
--ts-t-qa: #fbbf24;
--pi-color-grey-10: #12141a;
--pi-color-grey-25: #161922;
--pi-color-grey-50: #1a1e28;
--pi-color-grey-75: #1f2430;
--pi-color-grey-100: #262c3a;
--pi-color-grey-200: #333a4b;
--pi-color-grey-300: #4b5265;
--pi-color-grey-400: #8a91a4;
--pi-color-grey: #a9b0c1;
--pi-color-grey-900: #e8eaf1;
--pi-color-black: #f4f5f9;
--pi-color-black-200: #d8dbe6;
--pi-color-background-400: #0f1116;
--pi-color-background-300: #333a4b;
--pi-color-white-900: #1c202b;
@include dark-ramp('primary', #ae5de5);
@include dark-ramp('accent', #339be6);
@include dark-ramp('highlighted', #c79bf0);
@include dark-ramp('success', #34c98a);
@include dark-ramp('warn', #f07070);
--pi-theme-field-surface: var(--ts-surface);
--pi-theme-field-border-color: #333a4b;
--pi-theme-field-border-color-hover: #4b5265;
background-color: var(--ts-bg);
// Surfaces that resolve to `--pi-color-white` (which must keep meaning "white"
// for text on brand fills), redirected one by one.
.pi-card.pi-color__white,
.pi-panel.pi-color__white {
background-color: var(--ts-surface) !important;
}
.pi-card.pi-color__grey,
.pi-list-item.pi-color__grey {
background-color: var(--ts-surface-raised) !important;
}
.mat-mdc-notch-piece {
background-color: var(--ts-surface) !important;
}
.mat-mdc-dialog-surface {
background-color: var(--ts-surface-raised) !important;
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

30
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

31
codegen.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
/**
* Typed GraphQL operations for the web client, generated from the API schema
* (apps/api/schema.gql, written by the API on start in development).
*
* npx nx run web:codegen
*/
const plugins = ['typescript-operations', 'typed-document-node'];
const config: CodegenConfig = {
overwrite: true,
schema: 'apps/api/schema.gql',
config: {
enumsAsTypes: true,
useTypeImports: true,
avoidOptionals: { field: true, inputValue: false },
scalars: { ID: 'string' },
},
generates: {
'libs/web/tracker/infrastructure/src/lib/graphql/generated.ts': {
documents: 'libs/web/tracker/infrastructure/src/lib/graphql/*.graphql',
plugins,
},
'libs/web/identity/infrastructure/src/lib/graphql/generated.ts': {
documents: 'libs/web/identity/infrastructure/src/lib/graphql/*.graphql',
plugins,
},
},
};
export default config;

85
docker-compose.yml Normal file
View File

@@ -0,0 +1,85 @@
# TaskStream: web + API + infrastructure.
# docker compose up -d --build → everything: web on http://localhost:8080
# docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`)
#
# Building needs access to Pireon's Nexus (@pi/ui): the token is read from ~/.npmrc as a build secret.
services:
web:
build:
context: .
target: web
secrets: [npmrc]
image: task-stream-web
ports: ['8080:80']
depends_on:
api: { condition: service_healthy }
restart: unless-stopped
api:
build:
context: .
target: api
secrets: [npmrc]
image: task-stream-api
ports: ['3000:3000']
environment:
POSTGRES_URL: postgres://taskstream:taskstream@postgres:5432/taskstream
# No migrations yet: TypeORM creates the event store and lookup tables.
POSTGRES_SYNCHRONIZE: 'true'
MONGO_URL: mongodb://mongo:27017/taskstream_read
REDIS_URL: redis://redis:6379
EVENT_STREAM_KEY: task-stream:events
EVENTS_BRIDGE_ENABLED: 'true'
PIREON_BASE_URL: https://oauth.pireon.dev
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
GRAPHQL_PLAYGROUND: 'true'
# The SDL file is only useful in development (codegen).
GRAPHQL_SCHEMA_FILE: ''
depends_on:
postgres: { condition: service_healthy }
mongo: { condition: service_healthy }
redis: { condition: service_healthy }
restart: unless-stopped
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: taskstream
POSTGRES_PASSWORD: taskstream
POSTGRES_DB: taskstream
ports: ['5432:5432']
volumes: [postgres-data:/var/lib/postgresql/data]
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U taskstream']
interval: 5s
retries: 10
mongo:
image: mongo:8
ports: ['27017:27017']
volumes: [mongo-data:/data/db]
healthcheck:
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
interval: 5s
retries: 10
redis:
image: redis:7-alpine
command: ['redis-server', '--appendonly', 'yes']
ports: ['6379:6379']
volumes: [redis-data:/data]
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
retries: 10
volumes:
postgres-data:
mongo-data:
redis-data:
secrets:
npmrc:
file: ${HOME}/.npmrc

60
docs/01-vision-general.md Normal file
View File

@@ -0,0 +1,60 @@
# 1. Visión general
## Qué resuelve
| Necesidad | Cómo se resuelve |
| ---------------- | ----------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
proyecto (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`).
## Stack
| Pieza | Tecnología |
| ------------------- | ----------------------------------------------------------------------------------------------------------- |
| Monorepo | Nx 23 |
| API | NestJS 11, `@nestjs/cqrs`, GraphQL (Apollo Server 5, code-first) con subscriptions `graphql-ws` |
| Escritura | PostgreSQL 17: _event store_ + tablas de lookup (TypeORM) |
| Escritura → lectura | Redis 7: Redis Stream (eventos) y pub/sub (tiempo real); también caché de sesiones |
| Lectura | MongoDB 8: vistas desnormalizadas (Mongoose) |
| Identidad | Pireon (OAuth2 / Laravel Passport) |
| Cliente | Angular 22 (standalone, zoneless, Signal Forms), NgRx Signals (plugin de eventos), Apollo Angular, `@pi/ui` |
| Tests | Jest (API), Vitest (web), e2e de la API contra Docker |
| Despliegue | Docker: imagen Node de la API y nginx para la web |
## Estructura del monorepo
```
apps/
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
libs/
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
docs/ esta documentación
codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql
Dockerfile dos destinos: api y web
docker-compose.yml
```
API y web comparten la misma estructura: **contexto** (`shared`, `identity`, `tracker`) × **capa** (`domain`,
`application`, `infrastructure`, `presenters`). Los imports siguen el mismo patrón:
`@task-stream/api/tracker/domain`, `@task-stream/web/tracker/application`…
### Contextos
- **shared**: piezas comunes. En la API, event store, Redis, `VersionedAggregateRoot`, errores de dominio. En la web, el
cliente GraphQL y la integración de `@pi/ui`.
- **identity**: quién es el usuario (Pireon), sesiones, inicio de sesión y autorización global.
- **tracker**: el producto (proyectos, tickets, comentarios, releases, actividad).

168
docs/02-arquitectura.md Normal file
View File

@@ -0,0 +1,168 @@
# 2. Arquitectura
TaskStream combina tres ideas:
1. **Arquitectura hexagonal** (puertos y adaptadores), aplicada igual en la API y en la web.
2. **CQRS**: las escrituras (commands) y las lecturas (queries) van por caminos y bases de datos distintas.
3. **Event sourcing** en la API: lo que se guarda no es el estado de los agregados sino sus eventos.
## 2.1 Capas hexagonales
Cada contexto se divide en cuatro capas, cada una en su propia librería Nx:
```mermaid
flowchart LR
P[presenters<br/>GraphQL · HTTP · páginas] --> A[application<br/>services · commands · queries · event handlers · stores]
A --> D[domain<br/>agregados · eventos · reglas · read models · puertos]
I[infrastructure<br/>Postgres · Mongo · Redis · Pireon · Apollo] -. implementa puertos .-> A
I -.-> D
```
| Capa | Contiene | Puede depender de |
| ---------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` |
| `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` |
| `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` |
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, páginas Angular) | `domain`, `application`, `presenters` |
Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`).
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
- `presenters` nunca importa `infrastructure` ni usa los buses CQRS: habla con los `*.service.ts` (API) o con las facades (web).
- `infrastructure` nunca importa `presenters`.
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web
no depende de la API (y viceversa).
### Composición
Las apps son la _composition root_: montan cada contexto de fuera hacia dentro.
```ts
// apps/api/src/app/app.module.ts
SharedModule.withInfrastructure(SharedInfrastructureModule.forRootAsync({ … }));
TrackerPresentersModule.withApplication(
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule),
);
```
```ts
// apps/web/src/app/app.config.ts
provideTrackerInfrastructure(); // puertos → adaptadores GraphQL
{ provide: Notifications, useExisting: ToastService } // puerto de la aplicación → toasts de @pi/ui
```
## 2.2 Flujo completo de una escritura
```mermaid
sequenceDiagram
autonumber
participant UI as Web (store)
participant R as Resolver (presenters)
participant S as IssuesService
participant H as CommandHandler
participant AR as AggregateRehydrator
participant ES as Event store (Postgres)
participant RL as EventStoreRelay
participant RS as Redis Stream
participant EB as EventsBridge
participant EH as @EventsHandler (proyecciones)
participant M as MongoDB
participant PS as Redis pub/sub
UI->>R: mutation changeIssueStatus
R->>S: changeStatus(actor, issue, status)
S->>H: CommandBus.execute(ChangeIssueStatusCommand)
H->>AR: rehydrate(issueId, Issue)
AR->>ES: getEventsByStreamId
ES-->>AR: eventos → loadFromHistory
H->>H: issue.changeStatus() → apply(IssueStatusChangedEvent)
H->>ES: commitAndWait() → INSERT (stream_id, position)
H-->>UI: CommandResult { id, key, version }
RL->>ES: eventos con published_at IS NULL
RL->>RS: XADD
EB->>RS: XREADGROUP (consumer group)
EB->>EH: handle(evento) en orden, esperando
EH->>M: UpsertMaterialized*Repository (si version es mayor)
EH->>PS: ChangeFeed.publish*
PS-->>UI: subscription issueChanged
EB->>RS: XACK
```
### Escritura: event sourcing en PostgreSQL
- Los agregados (`Project`, `Issue`, `Comment`, `Release`) extienden `VersionedAggregateRoot`, que extiende el
`AggregateRoot` de `@nestjs/cqrs` y añade la versión.
- **Crear**: una factory `@Injectable()` construye el agregado y aplica el evento de creación (`skipHandler`).
- **Modificar**: el `AggregateRehydrator` carga los eventos del stream y los reproduce (`loadFromHistory`); los métodos
del agregado validan y hacen `apply(new XEvent(…))`; el estado solo cambia en los handlers `onXEvent`.
- **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla
`events`. `commitAndWait()` espera a que la escritura termine (el `commit()` de Nest no devuelve la promesa).
- **Concurrencia optimista**: `UNIQUE(stream_id, position)`. Si dos peticiones modifican el mismo agregado a la vez, la
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
### De escritura a lectura: Redis
- El **`EventStoreRelay`** copia los eventos nuevos (`published_at IS NULL`) al Redis Stream `task-stream:events`,
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_.
- El **`EventsBridge`** consume el stream con un _consumer group_, deserializa cada evento (`EventClsRegistry` +
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
### Lectura: MongoDB
- Las proyecciones son `@EventsHandler` de la capa `application/event-handlers`, uno por evento (más el de actividad,
que escucha todos). Escriben a través de los puertos `UpsertMaterialized*Repository`.
- Cada documento guarda la `version` del último evento aplicado; el upsert solo se aplica si la versión es mayor, así
que reentregar un evento no tiene efecto.
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
`domain/read-models`.
### Tiempo real
Tras actualizar Mongo, el handler publica en Redis pub/sub (`ChangeFeed`). Las subscriptions GraphQL escuchan esos
canales (uno por proyecto, ticket o usuario), así que funcionan con varias instancias de la API. Como se publica
**después** de proyectar, quien recibe el aviso siempre lee el estado nuevo.
### Consistencia eventual
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
milisegundos después. Consecuencias:
- El cliente reintenta las queries que devuelven _not found_ justo después de crear algo.
- Si necesitas el dato actualizado, espera un evento de subscription con `version >= result.version`.
- Las reglas que necesitan datos exactos **no** leen Mongo: por ejemplo, "no publicar una release con tickets sin
terminar" reconstruye esos tickets desde el event store (`ReleaseIssuesRepository`).
## 2.3 El cliente web
El cliente replica las capas (ver [Cliente web](04-cliente-web.md)):
```mermaid
flowchart LR
Page[Página Angular<br/>presenters] -- métodos --> F[Facade<br/>application]
F -- injectDispatch --> Ev((Eventos))
Ev --> Red[withReducer<br/>estado del store]
Ev --> Eff[withEventHandlers<br/>efectos]
Eff -- puertos --> GW[Gateways GraphQL<br/>infrastructure]
GW -- respuesta --> Eff
Eff -- emite eventos api / realtime --> Ev
Red -- señales --> F
F -- señales --> Page
```
## 2.4 Diagrama de despliegue
```mermaid
flowchart LR
B[Navegador] -->|:8080| N[nginx<br/>web]
N -->|/api · /graphql · WS| A[API NestJS]
A --> PG[(PostgreSQL<br/>event store)]
A --> R[(Redis<br/>stream · pub/sub · caché)]
A --> MG[(MongoDB<br/>read model)]
A -->|/api/user · /oauth/token| P[Pireon]
```

277
docs/03-api.md Normal file
View File

@@ -0,0 +1,277 @@
# 3. API
API GraphQL en `/graphql` (HTTP para queries y mutations, WebSocket `graphql-ws` para subscriptions) más unos pocos
endpoints REST bajo `/api`. El esquema completo se genera en `apps/api/schema.gql`.
## 3.1 Modelo de dominio (contexto `tracker`)
```mermaid
classDiagram
class Project {
key: "TS"
name
members: (userId, role)[]
addMember() changeMemberRole() removeMember() updateDetails()
}
class Issue {
key: "TS-42"
type · area · priority · status
assigneeId · releaseId · labels · figmaUrl
links: (type, target)[]
changeStatus() assign() linkTo() unlink() setRelease() updateDetails()
}
class Comment {
body · authorId
edit() delete()
}
class Release {
name · status · targetDate
update() ship() archive()
}
Project "1" --> "*" Issue
Issue "1" --> "*" Comment
Project "1" --> "*" Release
Release "0..1" <-- "*" Issue
Issue "*" --> "*" Issue : links
```
### Roles por proyecto
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
| -------------------------------- | --- | --------------------------------- | -------------------------------------------- |
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
| `ADMIN` | ✅ | ✅ | ✅ |
| `MEMBER` | ✅ | ✅ | ❌ |
| `VIEWER` | ✅ | ❌ | ❌ |
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
el proyecto existe.
### Workflow de un ticket
```mermaid
stateDiagram-v2
[*] --> BACKLOG
BACKLOG --> TODO
BACKLOG --> IN_PROGRESS
BACKLOG --> CANCELLED
TODO --> BACKLOG
TODO --> IN_PROGRESS
TODO --> CANCELLED
IN_PROGRESS --> TODO
IN_PROGRESS --> IN_REVIEW
IN_PROGRESS --> READY_FOR_QA
IN_PROGRESS --> CANCELLED
IN_REVIEW --> IN_PROGRESS
IN_REVIEW --> READY_FOR_QA
IN_REVIEW --> DONE
READY_FOR_QA --> IN_QA
READY_FOR_QA --> IN_PROGRESS
IN_QA --> DONE
IN_QA --> QA_FAILED
QA_FAILED --> IN_PROGRESS
QA_FAILED --> TODO
DONE --> TODO
DONE --> IN_PROGRESS
CANCELLED --> BACKLOG
```
- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial.
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual.
### Otras reglas
- **Proyecto**: clave de 2 a 10 caracteres alfanuméricos que empieza por letra y es única (los tickets se numeran
`CLAVE-1`, `CLAVE-2`…).
- **Asignación**: solo a miembros del proyecto. Para añadir miembros, el usuario debe existir y estar activo en Pireon.
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
### Eventos de dominio
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
## 3.2 GraphQL
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`).
### Queries
| Query | Devuelve |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro |
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
### Mutations
Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations |
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
| Comentarios | `addComment`, `editComment`, `deleteComment` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
### Subscriptions
| Subscription | Se emite cuando |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release |
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
### Ejemplos
```graphql
mutation {
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
id
key
version
}
}
mutation {
createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
id
key
version
}
}
mutation {
changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) {
version
}
}
mutation {
linkIssues(input: { issueId: "<FE>", type: DEPENDS_ON, targetIssueId: "<BE>" }) {
version
}
}
query {
project(idOrKey: "SHOP") {
stats {
total
open
byStatus {
key
count
}
byAssignee {
user {
name
}
count
}
}
board {
status
total
issues {
key
title
assignee {
name
}
}
}
releases {
name
status
progress {
percent
}
}
}
issue(idOrKey: "SHOP-1") {
status
allowedTransitions
links {
type
direction
issueKey
}
comments {
items {
author {
name
}
body
}
}
history {
summary
occurredAt
}
}
}
```
```ts
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'ws://localhost:3000/graphql',
connectionParams: { authorization: `Bearer ${token}` },
});
client.subscribe(
{
query: 'subscription($p: ID!) { issueChanged(projectId: $p) { action version issue { key status } } }',
variables: { p: projectId },
},
{ next: console.log, error: console.error, complete: () => {} },
);
```
### Errores
Los errores de dominio llegan como errores GraphQL con `extensions.code`:
| Código | Significado |
| ----------------- | -------------------------------------------------------------- |
| `UNAUTHENTICATED` | Falta el token o Pireon lo rechaza |
| `NOT_FOUND` | No existe o no eres miembro |
| `FORBIDDEN` | No tienes permiso (p. ej. un `VIEWER` modificando) |
| `VALIDATION` | Datos inválidos (clave de proyecto, URL de Figma…) |
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
## 3.3 REST
| Endpoint | Uso |
| --------------------------- | ----------------------------------------------------------------------------------------------- |
| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) |
| `GET /api/auth/config` | Configuración para iniciar sesión con Pireon desde el navegador (público) |
| `POST /api/auth/token` | Proxy de `/oauth/token` de Pireon: `authorization_code`, `password` y `refresh_token` (público) |
| `POST /api/webhooks/pireon` | Webhooks de Pireon firmados con HMAC (`user.deactivated`, `token.revoked`) |
Ver [Autenticación](05-autenticacion.md).

211
docs/04-cliente-web.md Normal file
View File

@@ -0,0 +1,211 @@
# 4. Cliente web
Angular 22 (componentes standalone, zoneless, Signal Forms) en `apps/web`, con la misma arquitectura hexagonal que la
API repartida en `libs/web/*`.
## 4.1 Librerías
| Librería | Contenido |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` |
| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` |
| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` |
| `web/identity/presenters` | Página de login, callback de Pireon, `authGuard` / `guestGuard`, menú de usuario |
| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) |
| `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos |
| `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) |
| `web/tracker/presenters` | Páginas: proyectos, tablero, ticket, releases, seguimiento, actividad y miembros |
## 4.2 Estado: NgRx Signals con eventos
Las páginas **no llaman a servicios ni a los stores**: hablan con una **facade**, que lee el estado como señales y
traduce cada intención en un evento. Los stores reaccionan con reducers (estado) y con handlers (efectos), y los efectos
emiten nuevos eventos con el resultado.
Cada store agrupa sus eventos por origen:
- `*PageEvents`: lo que hace el usuario (`opened`, `issueMoved`, `issueCreateRequested`…).
- `*ApiEvents`: lo que responde la API (`loaded`, `loadFailed`, `commandSucceeded`…).
- `*RealtimeEvents`: lo que llega por subscription (`issueChanged`, `activityAdded`…).
Ejemplo real (`BoardStore`, simplificado):
```ts
export const boardPageEvents = eventGroup({
source: 'Board Page',
events: {
opened: type<BoardFilter>(),
issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(),
},
});
export const BoardStore = signalStore(
withState<BoardState>({ filter: null, columns: [], loading: false, … }),
withReducer(
on(boardPageEvents.opened, ({ payload }) => ({ filter: payload, loading: true })),
on(boardApiEvents.loaded, ({ payload }) => ({ columns: payload, loading: false })),
// Optimista: la tarjeta se mueve al instante…
on(boardPageEvents.issueMoved, ({ payload }, s) => ({ columns: moveIssue(s.columns, payload.issueId, payload.to) })),
on(boardRealtimeEvents.issueChanged, ({ payload }, s) => ({ columns: applyIssueChange(s.columns, payload.issue, s.filter) })),
),
withEventHandlers((store, events = inject(Events), issues = inject(IssuesGateway)) => ({
load$: events.on(boardPageEvents.opened).pipe(switchMap(({ payload }) => issues.board(payload).pipe(
mapResponse({ next: boardApiEvents.loaded, error: (e) => boardApiEvents.loadFailed(describeError(e)) }),
))),
// …y vuelve a su sitio si la API rechaza el cambio.
move$: events.on(boardPageEvents.issueMoved).pipe(mergeMap(({ payload }) =>
issues.changeStatus({ issueId: payload.issueId, status: payload.to, reason: payload.reason }).pipe(
mapResponse({ next: () => null, error: (e) => boardApiEvents.issueMoveFailed(describeError(e)) }),
))),
})),
);
```
### Facades
Cada store tiene su facade en `application`, y es lo **único** que exportan esas librerías (junto al puerto
`Notifications`): los stores y los eventos quedan como detalle interno.
```ts
@Injectable()
export class BoardFacade {
private readonly store = inject(BoardStore);
private readonly dispatch = injectDispatch(boardPageEvents);
readonly columns = this.store.columns; // estado: señales de solo lectura
readonly loading = this.store.loading;
/** Abre el tablero si es otro proyecto; si es el mismo, solo cambia el filtro. */
show(filter: BoardFilter): void {
const same = this.store.filter()?.projectId === filter.projectId;
if (same) this.dispatch.filterChanged(filter);
else this.dispatch.opened(filter);
}
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason });
}
}
export const provideBoardFacade = () => [BoardStore, BoardFacade];
```
```ts
// En la página
@Component({ providers: [provideBoardFacade()], … })
export class BoardPage {
protected readonly board = inject(BoardFacade);
drop(event) { this.board.moveIssue(issueId, to); }
}
```
Qué gana la página:
- **Intenciones, no eventos**: `board.moveIssue(…)` en lugar de conocer `boardPageEvents` y la forma de su payload.
- **Contexto implícito**: `ProjectFacade` e `IssueFacade` ponen el id del proyecto o del ticket abierto
(`project.addMember(userId)`, `issue.addComment(body)`); si aún no ha cargado, el comando se ignora.
- **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
| Facade | Store | Ámbito | Responsabilidad |
| ---------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
hijas de `/p/:key` reciben la misma `ProjectFacade`.
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
### Tiempo real y consistencia
- Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…).
- Los cambios se fusionan por `version`: si llega un evento más antiguo que lo que hay en pantalla, se ignora.
- Tras crear algo, la query puede devolver _not found_ unos milisegundos (el read model es eventualmente consistente);
los stores lo reintentan hasta 5 veces.
- Los resultados de los comandos se comunican con el puerto `Notifications` (en la app, toasts de `pi-snack-bar`).
## 4.3 GraphQL
- **Apollo** (`apollo-angular` + `@apollo/client` 4) con la caché **desactivada**: los stores de NgRx son la única
fuente de verdad.
- Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la
sesión se cierra.
- Las operaciones están en `libs/web/*/infrastructure/src/lib/graphql/*.graphql`. Tras cambiar la API o una operación:
```sh
npx nx serve api # reescribe apps/api/schema.gql
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
```
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`).
## 4.4 `@pi/ui`
La librería de componentes de Pireon se instala desde su Nexus (scope `@pi`, configurado en `.npmrc`; el token va en
`~/.npmrc`). Se integra igual que en `landing-web`, y conviene conocer sus particularidades:
| Particularidad | Cómo se resuelve |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `styles/common/index.scss` no compila con Material ≥ 19 | `apps/web/src/styles.scss` importa sus partes una a una y declara el tema con `mat.theme` |
| No trae modo oscuro | Redefinimos sus tokens `--pi-color-*` bajo `html.dark-mode .pi-shell` (paleta Pireon, igual que la landing) |
| `PiThemeService` persiste mal el modo oscuro (`"false"` se lee como verdadero) | `AppearanceService` guarda su propia preferencia |
| No trae SVGs: `pi-icon` busca en el registro de Material | `shared/ui/src/lib/icons.ts` registra los iconos en línea, incluidos los que usa la propia librería (`eye`, `eye-off`, `add`…) |
| Sus textos (errores de campos, "Cerrar") van por ngx-translate | `providePiStrings()` los registra al arrancar, sin peticiones HTTP |
| Varios inputs son obligatorios (`input.required`) | `pi-note` necesita `gap`, `status`, `title` y `text` |
| Los campos usan Signal Forms | `[formField]="form.campo"` para formularios; `[(value)]` para controles sueltos |
| Drawers y modales se abren con `MatDialog` | `DrawerService.open(título, template)` con `getDrawerConfigs` de la librería |
| `pi-search-field` es un buscador plegable (un icono) | Para un campo de búsqueda normal se usa `pi-text-field` |
| `button` globales: `min-width`, padding y radio con selectores muy específicos | Los botones propios de la app usan `.ts-icon-button`; `pi-action-button size="w100"` necesita `box-sizing` (en `styles.scss`) |
| Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar (`(touch)`) |
Componentes en uso: `pi-action-button`, `pi-icon-button`, `pi-icon`, `pi-note`, `pi-text-field`, `pi-email-field`,
`pi-password-field`, `pi-textarea`, `pi-select-field`, `pi-segmented-control`, `pi-skeleton`, `pi-spinner`,
`pi-empty-state`, `pi-divider`, `pi-drawer` y `pi-snack-bar`.
## 4.5 Diseño
TaskStream es un producto de la plataforma Pireon y se ve como tal: comparte con pireon.dev (`landing-web`) el logo,
la paleta, la tipografía, la cabecera y las superficies. `@pi/ui` aporta los controles; lo propio vive en
`apps/web/src/styles.scss` como tokens CSS, con su versión oscura bajo `html.dark-mode .pi-shell`.
| De pireon.dev | En TaskStream |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Logo (`logo.png`, `favicon.ico`) | `apps/web/public`, pintado por `LogoComponent` |
| Rampas `primary` (#a347e1) y `accent` (#1879bf) sacadas del logo | Las mismas `brand-ramp` / `dark-ramp` |
| Inter; fondo `#f7f7fa` (oscuro `#0f1116`) | Igual (`--ts-font-body`, `--ts-bg`) |
| Cabecera translúcida con desenfoque, borde y sombra al hacer scroll | `AppShellComponent`, con «Pireon \| TaskStream» |
| Luces difusas violeta y azul arriba de cada página | `.ts-main::before/::after` |
| _Eyebrow_ azul en mayúsculas y titulares con un tramo en degradado | `.ts-eyebrow` y `.ts-gradient-text` |
| Tarjeta con lavado violeta→azul y borde en degradado (`landing-card`) | `.ts-panel` (paneles) y `.ts-card-link` (tarjetas que navegan, con elevación) |
| Cifras con `pi-stat-card` | Resumen de Seguimiento |
Encima, el lenguaje del producto: `--ts-font-mono` (Geist Mono) solo para datos (claves `WEB-12`, contadores,
versiones) y un color por estado (`--ts-s-*`) y por tipo de ticket (`--ts-t-*`), el mismo en tablero, tarjetas,
gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades
globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`.
## 4.6 Rutas
| Ruta | Página |
| -------------------------- | ----------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Mis proyectos |
| `/p/:key/board` | Tablero kanban (`?release=` filtra por release) |
| `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases |
| `/p/:key/dashboard` | Seguimiento |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros |
Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de
los componentes (`withComponentInputBinding`).

88
docs/05-autenticacion.md Normal file
View File

@@ -0,0 +1,88 @@
# 5. Autenticación (Pireon)
Los usuarios pertenecen a **Pireon** (`https://oauth.pireon.dev`, OAuth2 con Laravel Passport). TaskStream no guarda
contraseñas: solo valida tokens y guarda una copia local de nombre y email para mostrarlos.
## 5.1 Cómo valida la API un token
```mermaid
sequenceDiagram
participant C as Cliente
participant G as AuthGuard (presenters)
participant Q as ResolveSessionQuery
participant R as Redis (caché)
participant P as Pireon
C->>G: Authorization: Bearer <token>
G->>Q: resolveSession(token)
Q->>R: GET auth:session:sha256(token)
alt en caché
R-->>Q: usuario
else no está
Q->>P: GET /api/user
P-->>Q: usuario + roles
Q->>R: SET (AUTH_SESSION_TTL_SECONDS)
end
Q-->>G: User (o 401)
```
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
- Si Pireon marca al usuario como inactivo, el token se rechaza.
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
tokens y webhook).
- En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`).
- `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).
## 5.2 Iniciar sesión desde el navegador
Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
| Método | Necesita | Flujo |
| ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Entrar con Pireon** (Authorization Code + PKCE) | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y la redirect URI registrada | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
| **Email y contraseña** | El mismo cliente, con `PIREON_PASSWORD_GRANT=true` | `POST /api/auth/token` (`password`) |
Las dos opciones necesitan el cliente OAuth: sin `PIREON_CLIENT_ID` la API responde `503` y el login avisa de que no
está configurado. El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía.
(El login con token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como
`Bearer`.)
### Registrar TaskStream como cliente en Pireon
1. Crea el cliente: `POST https://oauth.pireon.dev/api/clients` (con un token tuyo de Pireon, scope `account:write`).
2. Registra como redirect URIs:
- `http://localhost:4200/auth/callback` (desarrollo)
- `http://localhost:8080/auth/callback` (Docker)
- la de producción
3. Pon `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET` en `.env` (o en el entorno del contenedor).
`GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`,
`passwordGrant`), o `null` si no hay cliente configurado; en ese caso el login muestra el aviso y no deja entrar.
### Sesión en la web
- La sesión (`accessToken`, `refreshToken`, caducidad) se guarda en `localStorage` y se restaura al arrancar.
- Un minuto antes de caducar se renueva con `refresh_token`. Si llegan varias peticiones a la vez, se renueva una sola vez.
- Si la API responde `UNAUTHENTICATED`, se cierra la sesión y se vuelve al login.
- El parámetro `state` del flujo PKCE protege contra CSRF; el `code_verifier` vive en `sessionStorage` solo durante la redirección.
## 5.3 Webhooks de Pireon (opcional)
Para que la desactivación de un usuario o la revocación de un token surtan efecto al instante (sin esperar a que
caduque la caché):
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
(`POST /api/clients/{client}/webhooks`).
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
sesiones cacheadas. Sin `PIREON_WEBHOOK_SECRET`, el endpoint rechaza todas las llamadas.
## 5.4 Usuarios en TaskStream
- Al validar un token, el usuario se guarda (nombre, email, activo) en la colección `users` de MongoDB.
- `users(search)` sincroniza antes el directorio de Pireon con el token del usuario, así que se puede añadir a gente
que aún no ha entrado en TaskStream (requiere el scope `users:read`).
- Los read models guardan el nombre de los usuarios desnormalizado: si alguien cambia su nombre en Pireon, las entradas
antiguas mantienen el nombre anterior.

93
docs/06-desarrollo.md Normal file
View File

@@ -0,0 +1,93 @@
# 6. Desarrollo
## 6.1 Requisitos
- Node 22 y npm 10.
- Docker (PostgreSQL, MongoDB y Redis).
- Acceso al Nexus de Pireon para `@pi/ui`. El scope está en `.npmrc` (sin token); el token va en tu `~/.npmrc`:
```sh
npm config set //nexus.pireon.dev/repository/npm-releases/:_authToken "<token>"
```
## 6.2 Puesta en marcha
```sh
npm install
docker compose up -d postgres mongo redis
cp .env.example .env # ajusta PIREON_*
npx nx serve api # http://localhost:3000 (GraphiQL en /graphql)
npx nx serve web # http://localhost:4200
```
`nx serve web` usa `apps/web/proxy.conf.json` para enviar `/api` y `/graphql` (también el WebSocket) a la API, así que
la web y la API comparten origen igual que en Docker.
## 6.3 Comandos
| Comando | Qué hace |
| -------------------------------- | --------------------------------------------------------------------------------- |
| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos |
| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) |
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
| `npx nx run-many -t build` | Builds de la API y la web |
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
| `npx nx graph` | Grafo de dependencias entre proyectos |
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
### Tests
| Dónde | Qué se prueba | Herramienta |
| ----------------------------------- | ---------------------------------------------------------------------- | ------------------------ |
| `api/*/domain` | Reglas de los agregados y reconstrucción desde el historial de eventos | Jest |
| `api/*/application` | Commands contra un event store en memoria (incluida la concurrencia) | Jest + `@nestjs/testing` |
| `apps/api/e2e` | Todo el flujo: GraphQL → Postgres → Redis → Mongo → subscriptions | Jest contra Docker |
| `web/*/domain`, `web/*/application` | Sesión y reducers del tablero | Jest / Vitest |
| `apps/web` | Rutas y guards | Vitest |
El e2e sustituye a Pireon por usuarios fijos (`token-ana`, `token-bob`…) con `overrideProvider(IdentityProvider)`; el
resto de la pila es real.
## 6.4 Cómo añadir una funcionalidad
Ejemplo: **marcar un ticket como bloqueado con un motivo**. El recorrido es siempre el mismo, de dentro hacia fuera.
### API
1. **Dominio** (`libs/api/tracker/domain`)
- Evento `issue-blocked.event.ts` con `@AutowiredEvent`, que extiende `TrackerEvent`.
- En `Issue`, el método `block(actorId, reason)` valida y hace `this.apply(new IssueBlockedEvent(…))`; el handler
`onIssueBlockedEvent(event)` cambia el estado.
- Test en `issue.spec.ts`, incluida la reconstrucción desde el historial (`replay`).
2. **Aplicación** (`libs/api/tracker/application`)
- `commands/block-issue.command.ts` y `block-issue.command-handler.ts`: rehidratar (`WriteAccess`), llamar al
dominio y `commitAndWait()`. Registrar el handler en `command-handlers.ts`.
- Método en `IssuesService`.
- Proyección `event-handlers/issue-blocked.event-handler.ts` (`@EventsHandler`) que actualiza el read model y avisa
por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`.
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
4. Arrancar la API para regenerar `apps/api/schema.gql`.
### Web
5. `npx nx run web:codegen` tras añadir la mutation a `issues.graphql`.
6. **Dominio**: método en el puerto `IssuesGateway`. **Infraestructura**: implementarlo en `GraphqlIssuesGateway`.
7. **Aplicación**: evento `blockRequested` en `issuePageEvents`, su efecto en `IssueStore` (`runCommand`) y el método
`block(reason)` en `IssueFacade`.
8. **Presenters**: botón y formulario en la página del ticket que llaman a `facade.block(reason)`.
El lint impide atajos: si un resolver intenta usar un repositorio o una página intenta usar Apollo, falla. Y como `application` solo
exporta facades, una página tampoco puede tocar un store ni despachar eventos por su cuenta.
## 6.5 Problemas conocidos
| Síntoma | Causa y solución |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `npm install` falla con `Cannot read properties of null (reading 'edgesOut')` | Bug de npm con un peer opcional de Vite que arrastra vitest 5. El `overrides` de `package.json` fija vitest; no lo quites |
| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` |
| `nx serve web` arranca en el puerto 3000 | Nx carga el `.env` raíz (`PORT=3000`) en todas las tareas; `apps/web/.env` lo fija en 4200 |
| La API no ve un evento nuevo al deserializar | La clase del evento no tiene `@AutowiredEvent` o no se importa |
| Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` |
| Tablas antiguas (`projects`, `issues`, `outbox`) en tu Postgres local | Restos de una versión anterior sin event sourcing; `docker compose down -v` empieza de cero |

84
docs/07-despliegue.md Normal file
View File

@@ -0,0 +1,84 @@
# 7. Despliegue
## 7.1 Docker
```sh
docker compose up -d --build # todo: web en http://localhost:8080
docker compose up -d postgres mongo redis # solo infraestructura
docker compose logs -f api
docker compose down # parar (-v borra los datos)
```
| Servicio | Imagen | Puerto | Función |
| ---------- | --------------------------- | ------ | ----------------------------------------------------------------------- |
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
| `mongo` | mongo:8 | 27017 | Read model |
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
### Imágenes
El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias:
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token se pasa como **build secret**
(`secrets.npmrc` → `~/.npmrc` en `docker-compose.yml`) y nunca queda en una capa.
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
## 7.2 Variables de entorno de la API
| Variable | Por defecto | Descripción |
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
| `PORT` | `3000` | Puerto HTTP |
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
| `MONGO_URL` | — (obligatoria) | Read model |
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth para el login desde la web |
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
## 7.3 Escalado
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`.
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
reparten los eventos y pierden el orden entre ellos.
## 7.4 Antes de producción
- Sustituir `POSTGRES_SYNCHRONIZE` por migraciones de TypeORM.
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
- Copias de seguridad de PostgreSQL: es la fuente de verdad.
## 7.5 Reconstruir el read model
MongoDB solo contiene proyecciones, así que se puede regenerar desde el event store:
1. Para la API (o desactiva el bridge en todas las instancias).
2. Borra la base de lectura (`taskstream_read`) salvo la colección `users`, que no sale de los eventos sino de Pireon.
3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;`
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.

73
docs/08-decisiones.md Normal file
View File

@@ -0,0 +1,73 @@
# 8. Decisiones de diseño
Registro breve de las decisiones que más condicionan el código, con su motivo y su coste.
### Arquitectura hexagonal con una librería Nx por capa
Las reglas de dependencia las comprueba el lint, no la disciplina del equipo. **Coste**: más librerías y más ficheros
(un command y un handler por fichero, como en el patrón de referencia del equipo).
### La misma estructura en API y web
`libs/api/<contexto>/<capa>` y `libs/web/<contexto>/<capa>`: quien conoce un lado se orienta en el otro.
### Event sourcing en PostgreSQL (y no en MongoDB)
El requisito era escritura en PostgreSQL y lectura en MongoDB. El event store (`events`) implementa la misma API que
el `MongoEventStore` del patrón de referencia (`persist`, `getEventsByStreamId`). **Diferencia**: un conflicto de
versión lanza `ConcurrencyError` en lugar de solo registrarse, para no perder cambios en silencio.
### `commitAndWait()`
El `commit()` de `@nestjs/cqrs` no devuelve la promesa del publisher: el command respondería OK aunque el event store
fallara. `VersionedAggregateRoot.commitAndWait()` espera la escritura y avanza la versión del agregado.
### Redis entre escritura y lectura, y un bridge que espera
El requisito era que Redis llevase los datos de escritura a lectura. El `EventsBridge` hace el papel del _change stream_
del patrón de referencia, pero no usa `eventBus.subject$.next()`: así los handlers se ejecutarían en paralelo
(`mergeMap`), un `IssueStatusChanged` podría proyectarse antes que su `IssueCreated` y un fallo se perdería. El bridge
espera a cada `@EventsHandler` en orden y confirma en Redis (`XACK`) al final.
### `EventClsRegistry` y `@AutowiredEvent` en el dominio
En el patrón de referencia están en infraestructura, pero los eventos de dominio los usan; ponerlos allí rompería la
regla de capas.
### Lookups fuera de los agregados
La unicidad de claves de proyecto, la numeración de tickets y los nombres de release no caben en un agregado.
Se resuelven con tablas pequeñas en PostgreSQL. **Coste**: no forman parte de la misma transacción que los eventos
(una clave reservada puede quedar huérfana si el guardado falla después).
### Reglas críticas contra el event store, no contra Mongo
"No publicar una release con tickets sin terminar" se comprobaba primero contra el read model y fallaba si alguien
metía un ticket y publicaba justo después. Ahora reconstruye esos tickets desde los eventos.
### Proxy de tokens en la API
Pireon no permite CORS en `/oauth/token` y el `client_secret` no debe llegar al navegador.
`POST /api/auth/token` hace de intermediario, igual que la API de administración de la landing.
### Caché de Apollo desactivada en la web
El estado vive en los stores de NgRx (alimentados por queries y subscriptions). Tener dos fuentes de verdad
(Apollo cache y stores) solo añadiría inconsistencias.
### Estado web con el plugin de eventos de NgRx Signals
Los efectos se declaran con `withEventHandlers` (nombre en v22 de lo que antes era `withEffects`), y lo que emiten se
despacha automáticamente.
### Facades delante de los stores
Las páginas usan una facade por store (`BoardFacade`, `IssueFacade`…) y no conocen ni los stores ni los eventos, que
ya no se exportan. La facade expone el estado como señales y las intenciones como métodos, y pone el contexto (id del
proyecto o del ticket abierto). Así se puede cambiar cómo se gestiona el estado sin tocar las páginas, y la lógica de
presentación se prueba sin plantillas. **Coste**: una clase más por store, casi toda de reenvío.
### Imagen de la API con dependencias propias
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.

25
docs/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Documentación de TaskStream
TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI,
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
| Documento | Para qué |
| ----------------------------------------- | ------------------------------------------------------------------------ |
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` |
| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks |
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado |
| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) |
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.
**Empezar rápido**
```sh
docker compose up -d --build # web en http://localhost:8080
```
Para entrar hace falta un cliente OAuth de Pireon en `.env` (ver [Autenticación](05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon)).

Binary file not shown.

Binary file not shown.

157
eslint.config.mjs Normal file
View File

@@ -0,0 +1,157 @@
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
{
ignores: ['**/dist', '**/out-tsc', '**/vitest.config.*.timestamp*'],
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
depConstraints: [
// Hexagonal layers: dependencies only point inwards.
{
// Pure model. Only @nestjs/common (@Injectable factories) and
// @nestjs/cqrs (AggregateRoot) are allowed.
sourceTag: 'type:domain',
onlyDependOnLibsWithTags: ['type:domain'],
bannedExternalImports: [
'@nestjs/core',
'@nestjs/config',
'@nestjs/typeorm',
'@nestjs/mongoose',
'@nestjs/graphql',
'@nestjs/apollo',
'typeorm',
'mongoose',
'ioredis',
'graphql*',
'pg',
'@angular/*',
'@ngrx/*',
'@apollo/*',
'apollo-angular',
'@pi/ui',
],
},
{
// Use cases: services + CQRS handlers + ports.
sourceTag: 'type:application',
onlyDependOnLibsWithTags: ['type:domain', 'type:application'],
bannedExternalImports: [
'typeorm',
'mongoose',
'ioredis',
'graphql*',
'pg',
'@nestjs/typeorm',
'@nestjs/mongoose',
'@nestjs/graphql',
'@apollo/*',
'apollo-angular',
'@pi/ui',
],
},
{
// Driving adapters (GraphQL, HTTP): talk to application services only.
sourceTag: 'type:presenters',
onlyDependOnLibsWithTags: [
'type:domain',
'type:application',
'type:presenters',
],
bannedExternalImports: [
'typeorm',
'mongoose',
'ioredis',
'pg',
'@nestjs/typeorm',
'@nestjs/mongoose',
'@nestjs/cqrs',
'graphql-redis-subscriptions',
'@apollo/*',
'apollo-angular',
],
},
{
// Driven adapters (Postgres, Mongo, Redis, Pireon). Never presenters.
sourceTag: 'type:infrastructure',
onlyDependOnLibsWithTags: [
'type:domain',
'type:application',
'type:infrastructure',
],
},
{
sourceTag: 'type:app',
onlyDependOnLibsWithTags: ['*'],
},
// API (NestJS) bounded contexts. Never depend on the web client.
{
sourceTag: 'scope:api',
onlyDependOnLibsWithTags: ['scope:api-shared', 'scope:api-identity', 'scope:api-tracker'],
},
{
sourceTag: 'scope:api-shared',
onlyDependOnLibsWithTags: ['scope:api-shared'],
},
{
sourceTag: 'scope:api-identity',
onlyDependOnLibsWithTags: ['scope:api-identity', 'scope:api-shared'],
},
{
sourceTag: 'scope:api-tracker',
onlyDependOnLibsWithTags: ['scope:api-tracker', 'scope:api-identity', 'scope:api-shared'],
},
// Web client (Angular). Never depends on backend libraries.
{
sourceTag: 'scope:web',
onlyDependOnLibsWithTags: ['scope:web-shared', 'scope:web-identity', 'scope:web-tracker'],
},
{
sourceTag: 'scope:web-shared',
onlyDependOnLibsWithTags: ['scope:web-shared'],
},
{
sourceTag: 'scope:web-identity',
onlyDependOnLibsWithTags: ['scope:web-identity', 'scope:web-shared'],
},
{
sourceTag: 'scope:web-tracker',
onlyDependOnLibsWithTags: ['scope:web-tracker', 'scope:web-identity', 'scope:web-shared'],
},
],
},
],
},
},
{
files: [
'**/*.ts',
'**/*.tsx',
'**/*.cts',
'**/*.mts',
'**/*.js',
'**/*.jsx',
'**/*.cjs',
'**/*.mjs',
],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
ignoreRestSiblings: true,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
},
];

6
jest.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { Config } from 'jest';
import { getJestProjectsAsync } from '@nx/jest';
export default async (): Promise<Config> => ({
projects: await getJestProjectsAsync()
});

3
jest.preset.js Normal file
View File

@@ -0,0 +1,3 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = { ...nxPreset };

View File

@@ -0,0 +1,24 @@
# api-identity-application
Casos de uso de identidad.
- **Import**: `@task-stream/api/identity/application`
- **Capa**: `application` · **Tags**: `scope:api-identity`, `type:application`
## Contenido
- `IdentityService`: punto de entrada para los presenters.
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand`.
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
Ver [Autenticación](../../../../docs/05-autenticacion.md).
## Comandos
```sh
npx nx lint api-identity-application
npx nx test api-identity-application # Jest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];

View File

@@ -0,0 +1,11 @@
module.exports = {
displayName: 'api-identity-application',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
passWithNoTests: true,
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/api/identity/application',
};

View File

@@ -0,0 +1,12 @@
{
"name": "api-identity-application",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/identity/application/src",
"projectType": "library",
"tags": [
"scope:api-identity",
"type:application"
],
"// targets": "to see all targets run: nx show project api-identity-application --web",
"targets": {}
}

View File

@@ -0,0 +1,11 @@
export * from './lib/ports/identity-provider.port';
export * from './lib/ports/session-cache.port';
export * from './lib/ports/user-registry.port';
export * from './lib/commands/handle-identity-webhook.command';
export * from './lib/queries/resolve-session.query';
export * from './lib/queries/user.queries';
export * from './lib/identity-application.handlers';
export * from './lib/identity.service';
export * from './lib/identity-application.module';
export * from './lib/commands/issue-token.command';
export * from './lib/queries/get-authorization-config.query';

View File

@@ -0,0 +1,57 @@
import { Logger } from '@nestjs/common';
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { SessionCache } from '../ports/session-cache.port';
import { UserRegistry } from '../ports/user-registry.port';
/** Reacts to Pireon webhooks: `user.deactivated` and `token.revoked`. */
export class HandleIdentityWebhookCommand extends Command<void> {
constructor(
public readonly event: string,
public readonly data: Record<string, unknown>,
) {
super();
}
}
@CommandHandler(HandleIdentityWebhookCommand)
export class HandleIdentityWebhookHandler
implements ICommandHandler<HandleIdentityWebhookCommand>
{
private readonly logger = new Logger(HandleIdentityWebhookHandler.name);
constructor(
private readonly cache: SessionCache,
private readonly registry: UserRegistry,
) {}
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
const userId =
data['user_id'] ??
data['userId'] ??
(data['user'] as { id?: unknown } | undefined)?.id;
if (userId === undefined || userId === null) {
this.logger.warn(`Webhook ${event} without user id, ignored`);
return;
}
const id = String(userId);
switch (event) {
case 'user.deactivated':
await Promise.all([this.deactivate(id), this.cache.evictUser(id)]);
break;
case 'token.revoked':
// Only token hashes are cached, so drop every session of that user.
await this.cache.evictUser(id);
break;
default:
this.logger.debug(`Unhandled identity webhook ${event}`);
}
}
private async deactivate(userId: string): Promise<void> {
const user = await this.registry.findById(userId);
if (!user) return;
user.deactivate();
await this.registry.upsert(user);
}
}

View File

@@ -0,0 +1,19 @@
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { TokenGrant, TokenSet } from '@task-stream/api/identity/domain';
import { IdentityProvider } from '../ports/identity-provider.port';
/** Signs a user in (or refreshes the session) against the identity server. */
export class IssueTokenCommand extends Command<TokenSet> {
constructor(public readonly grant: TokenGrant) {
super();
}
}
@CommandHandler(IssueTokenCommand)
export class IssueTokenHandler implements ICommandHandler<IssueTokenCommand> {
constructor(private readonly provider: IdentityProvider) {}
execute({ grant }: IssueTokenCommand): Promise<TokenSet> {
return this.provider.issueToken(grant);
}
}

View File

@@ -0,0 +1,21 @@
import { IssueTokenHandler } from './commands/issue-token.command';
import { GetAuthorizationConfigHandler } from './queries/get-authorization-config.query';
import { HandleIdentityWebhookHandler } from './commands/handle-identity-webhook.command';
import { ResolveSessionHandler } from './queries/resolve-session.query';
import {
FindUserHandler,
FindUsersByIdsHandler,
SearchUsersHandler,
} from './queries/user.queries';
export const IdentityCommandHandlers = [
HandleIdentityWebhookHandler,
IssueTokenHandler,
];
export const IdentityQueryHandlers = [
GetAuthorizationConfigHandler,
ResolveSessionHandler,
FindUserHandler,
FindUsersByIdsHandler,
SearchUsersHandler,
];

View File

@@ -0,0 +1,32 @@
import { DynamicModule, Module, Type } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import {
IdentityCommandHandlers,
IdentityQueryHandlers,
} from './identity-application.handlers';
import { IdentityService } from './identity.service';
/**
* Use cases of the identity context. The infrastructure module that
* implements its ports (IdentityProvider, SessionCache, UserRegistry) is
* plugged in by the composition root.
*/
@Module({
imports: [CqrsModule],
providers: [
IdentityService,
...IdentityCommandHandlers,
...IdentityQueryHandlers,
],
exports: [IdentityService],
})
export class IdentityApplicationModule {
static withInfrastructure(
infrastructureModule: Type | DynamicModule,
): DynamicModule {
return {
module: IdentityApplicationModule,
imports: [infrastructureModule],
};
}
}

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
AuthorizationConfig,
TokenGrant,
TokenSet,
User,
} from '@task-stream/api/identity/domain';
import { HandleIdentityWebhookCommand } from './commands/handle-identity-webhook.command';
import { IssueTokenCommand } from './commands/issue-token.command';
import { GetAuthorizationConfigQuery } from './queries/get-authorization-config.query';
import { ResolveSessionQuery } from './queries/resolve-session.query';
import {
FindUserQuery,
FindUsersByIdsQuery,
SearchUsersQuery,
} from './queries/user.queries';
/** Entry point of the identity context for presenters (HTTP, GraphQL, WebSocket). */
@Injectable()
export class IdentityService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
/** Who owns this Pireon access token. Throws InvalidAccessTokenError. */
resolveSession(accessToken: string): Promise<User> {
return this.queryBus.execute(new ResolveSessionQuery(accessToken));
}
findUser(userId: string, accessToken?: string): Promise<User | null> {
return this.queryBus.execute(new FindUserQuery(userId, accessToken));
}
findUsers(userIds: string[]): Promise<User[]> {
return this.queryBus.execute(new FindUsersByIdsQuery(userIds));
}
searchUsers(term: string, accessToken?: string, limit = 20): Promise<User[]> {
return this.queryBus.execute(
new SearchUsersQuery(term, accessToken, Math.min(limit, 100)),
);
}
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
return this.commandBus.execute(
new HandleIdentityWebhookCommand(event, data),
);
}
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
issueToken(grant: TokenGrant): Promise<TokenSet> {
return this.commandBus.execute(new IssueTokenCommand(grant));
}
authorizationConfig(): Promise<AuthorizationConfig | null> {
return this.queryBus.execute(new GetAuthorizationConfigQuery());
}
}

View File

@@ -0,0 +1,31 @@
import {
AuthorizationConfig,
TokenGrant,
TokenSet,
User,
} from '@task-stream/api/identity/domain';
export class InvalidAccessTokenError extends Error {
constructor(message = 'Invalid or expired access token') {
super(message);
}
}
/** Outbound port to the external identity server (Pireon OAuth). */
export abstract class IdentityProvider {
/** Resolves who owns the token. Throws InvalidAccessTokenError when rejected. */
abstract whoAmI(accessToken: string): Promise<User>;
/** Looks a user up using the caller's token. Returns null if missing or not visible. */
abstract findUser(id: string, accessToken: string): Promise<User | null>;
abstract listUsers(accessToken: string): Promise<User[]>;
/** Exchanges a grant for tokens. Throws InvalidCredentialsError when rejected. */
abstract issueToken(grant: TokenGrant): Promise<TokenSet>;
/** Null when no OAuth client is configured for browsers. */
abstract authorizationConfig(): AuthorizationConfig | null;
}
export class InvalidCredentialsError extends Error {
constructor(message = 'Invalid credentials') {
super(message);
}
}

View File

@@ -0,0 +1,9 @@
import { User } from '@task-stream/api/identity/domain';
/** Short-lived cache of resolved tokens, so not every request hits the identity server. */
export abstract class SessionCache {
abstract get(accessToken: string): Promise<User | null>;
abstract set(accessToken: string, user: User): Promise<void>;
/** Drops every cached session of a user (deactivation, revoked tokens...). */
abstract evictUser(userId: string): Promise<void>;
}

View File

@@ -0,0 +1,12 @@
import { User } from '@task-stream/api/identity/domain';
/**
* Local read copy of the users that have interacted with task-stream.
* Used to show names/emails without calling the identity server.
*/
export abstract class UserRegistry {
abstract upsert(user: User): Promise<void>;
abstract findById(id: string): Promise<User | null>;
abstract findByIds(ids: string[]): Promise<User[]>;
abstract search(term: string, limit: number): Promise<User[]>;
}

View File

@@ -0,0 +1,16 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { AuthorizationConfig } from '@task-stream/api/identity/domain';
import { IdentityProvider } from '../ports/identity-provider.port';
export class GetAuthorizationConfigQuery extends Query<AuthorizationConfig | null> {}
@QueryHandler(GetAuthorizationConfigQuery)
export class GetAuthorizationConfigHandler
implements IQueryHandler<GetAuthorizationConfigQuery>
{
constructor(private readonly provider: IdentityProvider) {}
async execute(): Promise<AuthorizationConfig | null> {
return this.provider.authorizationConfig();
}
}

View File

@@ -0,0 +1,64 @@
import { User, UserFactory } from '@task-stream/api/identity/domain';
import {
IdentityProvider,
InvalidAccessTokenError,
} from '../ports/identity-provider.port';
import { SessionCache } from '../ports/session-cache.port';
import { UserRegistry } from '../ports/user-registry.port';
import {
ResolveSessionHandler,
ResolveSessionQuery,
} from './resolve-session.query';
const users = new UserFactory();
const ada = users.create({
id: '1',
name: 'Ada',
email: 'ada@example.com',
isActive: true,
roles: ['user'],
appRoles: ['editor'],
});
function setup(user: User = ada) {
const provider = {
whoAmI: jest.fn().mockResolvedValue(user),
findUser: jest.fn(),
listUsers: jest.fn(),
};
const cached = new Map<string, User>();
const cache = {
get: jest.fn(async (t: string) => cached.get(t) ?? null),
set: jest.fn(async (t: string, u: User) => void cached.set(t, u)),
evictUser: jest.fn(),
};
const registry = { upsert: jest.fn(async (_: User) => undefined) };
const handler = new ResolveSessionHandler(
provider as unknown as IdentityProvider,
cache as unknown as SessionCache,
registry as unknown as UserRegistry,
);
return { handler, provider, registry };
}
describe('ResolveSessionHandler', () => {
it('asks Pireon once, then serves from cache', async () => {
const { handler, provider, registry } = setup();
await handler.execute(new ResolveSessionQuery('token'));
const again = await handler.execute(new ResolveSessionQuery('token'));
expect(again).toEqual(ada);
expect(provider.whoAmI).toHaveBeenCalledTimes(1);
expect(registry.upsert).toHaveBeenCalledWith(ada);
});
it('rejects deactivated users', async () => {
const { handler } = setup(
users.create({ ...ada.toSnapshot(), isActive: false }),
);
await expect(
handler.execute(new ResolveSessionQuery('token')),
).rejects.toThrow(InvalidAccessTokenError);
});
});

View File

@@ -0,0 +1,41 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { User } from '@task-stream/api/identity/domain';
import {
IdentityProvider,
InvalidAccessTokenError,
} from '../ports/identity-provider.port';
import { SessionCache } from '../ports/session-cache.port';
import { UserRegistry } from '../ports/user-registry.port';
/** Resolves who owns an access token (cache → Pireon). */
export class ResolveSessionQuery extends Query<User> {
constructor(public readonly accessToken: string) {
super();
}
}
@QueryHandler(ResolveSessionQuery)
export class ResolveSessionHandler
implements IQueryHandler<ResolveSessionQuery>
{
constructor(
private readonly provider: IdentityProvider,
private readonly cache: SessionCache,
private readonly registry: UserRegistry,
) {}
async execute({ accessToken }: ResolveSessionQuery): Promise<User> {
const cached = await this.cache.get(accessToken);
if (cached) return cached;
const user = await this.provider.whoAmI(accessToken);
if (!user.isActive)
throw new InvalidAccessTokenError('User is deactivated');
await Promise.all([
this.cache.set(accessToken, user),
this.registry.upsert(user),
]);
return user;
}
}

View File

@@ -0,0 +1,85 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { User } from '@task-stream/api/identity/domain';
import { IdentityProvider } from '../ports/identity-provider.port';
import { UserRegistry } from '../ports/user-registry.port';
/**
* Finds a user locally, falling back to the identity server with the
* caller's token (and remembering the result).
*/
export class FindUserQuery extends Query<User | null> {
constructor(
public readonly userId: string,
public readonly accessToken?: string,
) {
super();
}
}
export class FindUsersByIdsQuery extends Query<User[]> {
constructor(public readonly userIds: string[]) {
super();
}
}
/**
* Searches users. With a token, the identity server directory is synced
* first so people who never logged in can be found too.
*/
export class SearchUsersQuery extends Query<User[]> {
constructor(
public readonly term: string,
public readonly accessToken?: string,
public readonly limit = 20,
) {
super();
}
}
@QueryHandler(FindUserQuery)
export class FindUserHandler implements IQueryHandler<FindUserQuery> {
constructor(
private readonly registry: UserRegistry,
private readonly provider: IdentityProvider,
) {}
async execute({ userId, accessToken }: FindUserQuery): Promise<User | null> {
const local = await this.registry.findById(userId);
if (local || !accessToken) return local;
const remote = await this.provider.findUser(userId, accessToken);
if (remote) await this.registry.upsert(remote);
return remote;
}
}
@QueryHandler(FindUsersByIdsQuery)
export class FindUsersByIdsHandler
implements IQueryHandler<FindUsersByIdsQuery>
{
constructor(private readonly registry: UserRegistry) {}
execute({ userIds }: FindUsersByIdsQuery): Promise<User[]> {
return this.registry.findByIds([...new Set(userIds)]);
}
}
@QueryHandler(SearchUsersQuery)
export class SearchUsersHandler implements IQueryHandler<SearchUsersQuery> {
constructor(
private readonly registry: UserRegistry,
private readonly provider: IdentityProvider,
) {}
async execute({
term,
accessToken,
limit,
}: SearchUsersQuery): Promise<User[]> {
if (accessToken) {
const remote = await this.provider.listUsers(accessToken).catch(() => []);
await Promise.all(remote.map((u) => this.registry.upsert(u)));
}
return this.registry.search(term, limit);
}
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,23 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"],
"target": "es2021",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "bundler",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,21 @@
# api-identity-domain
El usuario, tal como lo conoce TaskStream (sus datos vienen de Pireon).
- **Import**: `@task-stream/api/identity/domain`
- **Capa**: `domain` · **Tags**: `scope:api-identity`, `type:domain`
## Contenido
- `User` (agregado): nombre, email, activo, roles globales y roles en el cliente; `hasAppRole()`, `deactivate()`.
- `UserFactory`: construye usuarios a partir de Pireon o de la caché.
- Tipos del inicio de sesión: `TokenGrant`, `TokenSet`, `AuthorizationConfig`.
## Comandos
```sh
npx nx lint api-identity-domain
npx nx test api-identity-domain # Jest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];

View File

@@ -0,0 +1,11 @@
module.exports = {
displayName: 'api-identity-domain',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
passWithNoTests: true,
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/api/identity/domain',
};

View File

@@ -0,0 +1,12 @@
{
"name": "api-identity-domain",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/identity/domain/src",
"projectType": "library",
"tags": [
"scope:api-identity",
"type:domain"
],
"// targets": "to see all targets run: nx show project api-identity-domain --web",
"targets": {}
}

View File

@@ -0,0 +1,4 @@
export * from './lib/user';
export * from './lib/events/user-deactivated.event';
export * from './lib/factories/user.factory';
export * from './lib/token';

View File

@@ -0,0 +1,9 @@
import { AutowiredEvent, DomainEvent } from '@task-stream/api/shared/domain';
/** Pireon deactivated the user. */
@AutowiredEvent
export class UserDeactivatedEvent extends DomainEvent {
constructor(readonly userId: string) {
super('identity-server');
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { assertThat } from '@task-stream/api/shared/domain';
import { User, UserSnapshot } from '../user';
type UserProps = Omit<UserSnapshot, 'roles' | 'appRoles'> &
Partial<Pick<UserSnapshot, 'roles' | 'appRoles'>>;
/** Users are never created here (Pireon owns them): they are only (re)built. */
@Injectable()
export class UserFactory {
create(props: UserProps): User {
assertThat(props.id.length > 0, 'User id is required');
const user = new User(props.id);
user.name = props.name;
user.email = props.email;
user.isActive = props.isActive;
user.roles = [...(props.roles ?? [])];
user.appRoles = [...(props.appRoles ?? [])];
return user;
}
restore(snapshot: UserProps): User {
return this.create(snapshot);
}
}

View File

@@ -0,0 +1,27 @@
/** How a client proves who it is to get tokens from the identity server. */
export type TokenGrant =
| {
type: 'authorization_code';
code: string;
codeVerifier: string;
redirectUri: string;
}
| { type: 'password'; username: string; password: string }
| { type: 'refresh_token'; refreshToken: string };
export interface TokenSet {
accessToken: string;
refreshToken: string | null;
/** Seconds until the access token expires. */
expiresIn: number;
tokenType: string;
}
/** What a browser needs to start the Authorization Code + PKCE flow. */
export interface AuthorizationConfig {
authorizeUrl: string;
clientId: string;
scopes: string[];
/** Password grant can be disabled on the identity server. */
passwordGrant: boolean;
}

View File

@@ -0,0 +1,56 @@
import { VersionedAggregateRoot } from '@task-stream/api/shared/domain';
import { UserDeactivatedEvent } from './events/user-deactivated.event';
export interface UserSnapshot {
/** Pireon user id (string to stay provider-agnostic). */
id: string;
name: string;
email: string;
isActive: boolean;
/** Global roles in the identity server. */
roles: string[];
/** Roles granted inside this OAuth client (task-stream). */
appRoles: string[];
}
/**
* A person managed by the Pireon identity server. Not event-sourced here
* (Pireon owns users): UserFactory rebuilds it from Pireon or the local cache.
*/
export class User extends VersionedAggregateRoot implements UserSnapshot {
public name!: string;
public email!: string;
public isActive = true;
public roles: string[] = [];
public appRoles: string[] = [];
constructor(id: string) {
super();
this.id = id;
}
/** Global Pireon admins implicitly hold every app role. */
hasAppRole(role: string): boolean {
return this.appRoles.includes(role) || this.roles.includes('admin');
}
deactivate(): void {
if (!this.isActive) return;
this.apply(new UserDeactivatedEvent(this.id));
}
onUserDeactivatedEvent(): void {
this.isActive = false;
}
toSnapshot(): UserSnapshot {
return {
id: this.id,
name: this.name,
email: this.email,
isActive: this.isActive,
roles: [...this.roles],
appRoles: [...this.appRoles],
};
}
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "bundler",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,21 @@
# api-identity-infrastructure
Adaptadores de salida de identidad.
- **Import**: `@task-stream/api/identity/infrastructure`
- **Capa**: `infrastructure` · **Tags**: `scope:api-identity`, `type:infrastructure`
## Contenido
- `PireonIdentityProvider`: `GET /api/user`, `/api/users` y `POST /oauth/token` de Pireon.
- `RedisSessionCache`: tokens validados, guardados por hash SHA-256 y con TTL.
- `MongoUserRegistry`: copia local de usuarios (colección `users`).
## Comandos
```sh
npx nx lint api-identity-infrastructure
npx nx test api-identity-infrastructure # Jest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];

View File

@@ -0,0 +1,11 @@
module.exports = {
displayName: 'api-identity-infrastructure',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
passWithNoTests: true,
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/api/identity/infrastructure',
};

View File

@@ -0,0 +1,12 @@
{
"name": "api-identity-infrastructure",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/identity/infrastructure/src",
"projectType": "library",
"tags": [
"scope:api-identity",
"type:infrastructure"
],
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
"targets": {}
}

View File

@@ -0,0 +1 @@
export * from './lib/identity-infrastructure.module';

View File

@@ -0,0 +1,47 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import {
IdentityProvider,
SessionCache,
UserRegistry,
} from '@task-stream/api/identity/application';
import { UserFactory } from '@task-stream/api/identity/domain';
import { MongoUserRegistry } from './mongo/mongo-user.registry';
import { UserDocument, UserSchema } from './mongo/user.schema';
import { PIREON_CONFIG, PireonConfig } from './pireon/pireon.config';
import { PireonIdentityProvider } from './pireon/pireon-identity.provider';
import { RedisSessionCache } from './redis/redis-session.cache';
/** Driven adapters of the identity context: Pireon (HTTP), Redis and MongoDB. */
@Module({
imports: [
MongooseModule.forFeature([
{ name: UserDocument.name, schema: UserSchema },
]),
],
providers: [
UserFactory,
{
provide: PIREON_CONFIG,
inject: [ConfigService],
useFactory: (config: ConfigService): PireonConfig => ({
baseUrl: config.getOrThrow<string>('PIREON_BASE_URL'),
sessionTtlSeconds: Number(config.get('AUTH_SESSION_TTL_SECONDS', 60)),
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || undefined,
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
.split(/\s+/)
.filter(Boolean),
passwordGrant:
String(config.get('PIREON_PASSWORD_GRANT', 'true')) !== 'false',
}),
},
{ provide: IdentityProvider, useClass: PireonIdentityProvider },
{ provide: SessionCache, useClass: RedisSessionCache },
{ provide: UserRegistry, useClass: MongoUserRegistry },
],
exports: [IdentityProvider, SessionCache, UserRegistry],
})
export class IdentityInfrastructureModule {}

Some files were not shown because too many files have changed in this diff Show More