first commit
This commit is contained in:
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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user