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