first commit

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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