first commit
This commit is contained in:
31
.claude/settings.json
Normal file
31
.claude/settings.json
Normal 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
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
tmp
|
||||||
|
coverage
|
||||||
|
.nx
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
|
.claude
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
13
.editorconfig
Normal file
13
.editorconfig
Normal 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
29
.env.example
Normal 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
55
.gitignore
vendored
Normal 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
16
.npmrc
Normal 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
7
.prettierignore
Normal 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
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal 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
29
.vscode/launch.json
vendored
Normal 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
24
CLAUDE.md
Normal 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
43
Dockerfile
Normal 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
|
||||||
408
apps/api/e2e/tracker.e2e-spec.ts
Normal file
408
apps/api/e2e/tracker.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
apps/api/e2e/zz-ui-dev-server.e2e-spec.ts
Normal file
123
apps/api/e2e/zz-ui-dev-server.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
3
apps/api/eslint.config.mjs
Normal file
3
apps/api/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [...baseConfig];
|
||||||
10
apps/api/jest.config.cts
Normal file
10
apps/api/jest.config.cts
Normal 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',
|
||||||
|
};
|
||||||
15
apps/api/jest.e2e.config.cts
Normal file
15
apps/api/jest.e2e.config.cts
Normal 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
34
apps/api/package.json
Normal 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
78
apps/api/project.json
Normal 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
562
apps/api/schema.gql
Normal 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!
|
||||||
|
}
|
||||||
130
apps/api/src/app/app.module.ts
Normal file
130
apps/api/src/app/app.module.ts
Normal 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 {}
|
||||||
36
apps/api/src/app/config.schema.ts
Normal file
36
apps/api/src/app/config.schema.ts
Normal 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'),
|
||||||
|
});
|
||||||
39
apps/api/src/app/health.controller.ts
Normal file
39
apps/api/src/app/health.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apps/api/src/assets/.gitkeep
Normal file
0
apps/api/src/assets/.gitkeep
Normal file
23
apps/api/src/main.ts
Normal file
23
apps/api/src/main.ts
Normal 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();
|
||||||
19
apps/api/tsconfig.app.json
Normal file
19
apps/api/tsconfig.app.json
Normal 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
16
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/tsconfig.spec.json
Normal file
17
apps/api/tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
25
apps/api/webpack.config.js
Normal file
25
apps/api/webpack.config.js
Normal 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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
34
apps/web/eslint.config.mjs
Normal file
34
apps/web/eslint.config.mjs
Normal 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
38
apps/web/nginx.conf
Normal 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
97
apps/web/project.json
Normal 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
4
apps/web/proxy.conf.json
Normal 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
BIN
apps/web/public/favicon.ico
Normal file
Binary file not shown.
BIN
apps/web/public/logo-96.png
Normal file
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
BIN
apps/web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
47
apps/web/src/app/app.config.ts
Normal file
47
apps/web/src/app/app.config.ts
Normal 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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
26
apps/web/src/app/app.routes.spec.ts
Normal file
26
apps/web/src/app/app.routes.spec.ts
Normal 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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
12
apps/web/src/app/app.routes.ts
Normal file
12
apps/web/src/app/app.routes.ts
Normal 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
39
apps/web/src/app/app.ts
Normal 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
39
apps/web/src/index.html
Normal 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
5
apps/web/src/main.ts
Normal 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
551
apps/web/src/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/web/tsconfig.app.json
Normal file
9
apps/web/tsconfig.app.json
Normal 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
30
apps/web/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
apps/web/tsconfig.spec.json
Normal file
8
apps/web/tsconfig.spec.json
Normal 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
31
codegen.ts
Normal 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
85
docker-compose.yml
Normal 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
60
docs/01-vision-general.md
Normal 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
168
docs/02-arquitectura.md
Normal 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
277
docs/03-api.md
Normal 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
211
docs/04-cliente-web.md
Normal 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
88
docs/05-autenticacion.md
Normal 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
93
docs/06-desarrollo.md
Normal 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
84
docs/07-despliegue.md
Normal 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
73
docs/08-decisiones.md
Normal 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
25
docs/README.md
Normal 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)).
|
||||||
BIN
docs/TaskStream-Documentacion.docx
Normal file
BIN
docs/TaskStream-Documentacion.docx
Normal file
Binary file not shown.
BIN
docs/TaskStream-Documentacion.pdf
Normal file
BIN
docs/TaskStream-Documentacion.pdf
Normal file
Binary file not shown.
157
eslint.config.mjs
Normal file
157
eslint.config.mjs
Normal 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
6
jest.config.ts
Normal 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
3
jest.preset.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const nxPreset = require('@nx/jest/preset').default;
|
||||||
|
|
||||||
|
module.exports = { ...nxPreset };
|
||||||
24
libs/api/identity/application/README.md
Normal file
24
libs/api/identity/application/README.md
Normal 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).
|
||||||
3
libs/api/identity/application/eslint.config.mjs
Normal file
3
libs/api/identity/application/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import baseConfig from '../../../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [...baseConfig];
|
||||||
11
libs/api/identity/application/jest.config.cts
Normal file
11
libs/api/identity/application/jest.config.cts
Normal 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',
|
||||||
|
};
|
||||||
12
libs/api/identity/application/project.json
Normal file
12
libs/api/identity/application/project.json
Normal 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": {}
|
||||||
|
}
|
||||||
11
libs/api/identity/application/src/index.ts
Normal file
11
libs/api/identity/application/src/index.ts
Normal 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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
60
libs/api/identity/application/src/lib/identity.service.ts
Normal file
60
libs/api/identity/application/src/lib/identity.service.ts
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
libs/api/identity/application/tsconfig.json
Normal file
23
libs/api/identity/application/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
libs/api/identity/application/tsconfig.lib.json
Normal file
23
libs/api/identity/application/tsconfig.lib.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
libs/api/identity/application/tsconfig.spec.json
Normal file
16
libs/api/identity/application/tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
21
libs/api/identity/domain/README.md
Normal file
21
libs/api/identity/domain/README.md
Normal 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).
|
||||||
3
libs/api/identity/domain/eslint.config.mjs
Normal file
3
libs/api/identity/domain/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import baseConfig from '../../../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [...baseConfig];
|
||||||
11
libs/api/identity/domain/jest.config.cts
Normal file
11
libs/api/identity/domain/jest.config.cts
Normal 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',
|
||||||
|
};
|
||||||
12
libs/api/identity/domain/project.json
Normal file
12
libs/api/identity/domain/project.json
Normal 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": {}
|
||||||
|
}
|
||||||
4
libs/api/identity/domain/src/index.ts
Normal file
4
libs/api/identity/domain/src/index.ts
Normal 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';
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
libs/api/identity/domain/src/lib/factories/user.factory.ts
Normal file
25
libs/api/identity/domain/src/lib/factories/user.factory.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
libs/api/identity/domain/src/lib/token.ts
Normal file
27
libs/api/identity/domain/src/lib/token.ts
Normal 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;
|
||||||
|
}
|
||||||
56
libs/api/identity/domain/src/lib/user.ts
Normal file
56
libs/api/identity/domain/src/lib/user.ts
Normal 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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
23
libs/api/identity/domain/tsconfig.json
Normal file
23
libs/api/identity/domain/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
15
libs/api/identity/domain/tsconfig.lib.json
Normal file
15
libs/api/identity/domain/tsconfig.lib.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
libs/api/identity/domain/tsconfig.spec.json
Normal file
16
libs/api/identity/domain/tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
21
libs/api/identity/infrastructure/README.md
Normal file
21
libs/api/identity/infrastructure/README.md
Normal 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).
|
||||||
3
libs/api/identity/infrastructure/eslint.config.mjs
Normal file
3
libs/api/identity/infrastructure/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import baseConfig from '../../../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [...baseConfig];
|
||||||
11
libs/api/identity/infrastructure/jest.config.cts
Normal file
11
libs/api/identity/infrastructure/jest.config.cts
Normal 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',
|
||||||
|
};
|
||||||
12
libs/api/identity/infrastructure/project.json
Normal file
12
libs/api/identity/infrastructure/project.json
Normal 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": {}
|
||||||
|
}
|
||||||
1
libs/api/identity/infrastructure/src/index.ts
Normal file
1
libs/api/identity/infrastructure/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/identity-infrastructure.module';
|
||||||
@@ -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
Reference in New Issue
Block a user