Compare commits

...

2 Commits

Author SHA1 Message Date
1550332d41 refactor(api): buses only in services, atomic commands, @Prop schemas
- Mongo read models are @Schema/@Prop classes (SchemaFactory), still
  loose: the props declared are the indexed or shaped ones.
- CommandBus and QueryBus are used by the application services alone.
  Duplicating a ticket, bulk updates, CSV imports, proposals and
  emptying the trash chain several commands, so they are service
  methods now and their composite commands are gone; every command
  handler is atomic. No forwardRef is needed.
- The due-date and trash timers are entry points: they live in the
  presenters and call the services. The automations event handler has
  its own file and calls AutomationsService.
- The tracker's user directory asks IdentityService; the composition
  root hands the identity application module to the tracker
  infrastructure (withIdentity), with no global module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:56:59 +02:00
2c501a50d0 feat: uuid ids everywhere, pickers for linked tickets, @pi/ui checkboxes
- Postgres: every table has a uuid `id` (gen_random_uuid()); the event
  store keeps its relay order in `seq`, and the lookup tables their
  uniqueness in constraints of their own.
- MongoDB: `_id` is Mongo's ObjectId; `id` is the domain id (the same
  uuid as the aggregate's stream in Postgres), unique, and every query
  goes by it. Production starts from empty databases.
- Web: linking tickets and hanging one from a parent are picked from a
  searchable select (links across projects, parents within the project)
  instead of typing a key; every checkbox is @pi/ui's pi-checkbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:16:43 +02:00
59 changed files with 947 additions and 973 deletions

View File

@@ -24,11 +24,16 @@ 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 identityApplication = IdentityApplicationModule.withInfrastructure(
IdentityInfrastructureModule,
);
const identity = IdentityPresentersModule.withApplication(identityApplication);
// The tracker's user directory asks the identity context (IdentityService):
// the same module, so one identity context serves both.
const tracker = TrackerPresentersModule.withApplication(
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule),
TrackerApplicationModule.withInfrastructure(
TrackerInfrastructureModule.withIdentity(identityApplication),
),
);
@Module({
@@ -47,6 +52,8 @@ const tracker = TrackerPresentersModule.withApplication(
url: config.getOrThrow<string>('POSTGRES_URL'),
autoLoadEntities: true,
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'),
// uuid ids come from gen_random_uuid() (core since PostgreSQL 13).
uuidExtension: 'pgcrypto',
}),
}),

View File

@@ -23,14 +23,23 @@ flowchart LR
| `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` |
| `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` |
| `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` |
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, páginas Angular) | `domain`, `application`, `presenters` |
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, temporizadores, páginas Angular) | `domain`, `application`, `presenters` |
Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`).
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
- `presenters` nunca importa `infrastructure` ni usa los buses CQRS: habla con los `*.service.ts` (API) o con las facades (web).
- Los buses CQRS (`CommandBus`, `QueryBus`) solo los usan los `*.service.ts` de `application`; todo lo demás pasa por
un servicio: resolvers, controllers, temporizadores (recordatorios, papelera; por eso viven en `presenters`), el
handler de eventos de las automatizaciones y el directorio de usuarios del tracker (`IdentityService`). La única
excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas.
- Los comandos son atómicos: un handler no ejecuta otros comandos. Lo que encadena varios (duplicar un ticket, la
edición en bloque, importar un CSV, las propuestas, vaciar la papelera) es un método del servicio, que ejecuta los
comandos uno a uno con su bus. Así no hay ciclos entre servicios y handlers, ni `forwardRef`.
- Sin módulos globales entre contextos: el tracker recibe el módulo de aplicación de identidad en la raíz de composición
(`TrackerInfrastructureModule.withIdentity(...)`), el mismo con el que se monta ese contexto.
- `presenters` nunca importa `infrastructure`: habla con los `*.service.ts` (API) o con las facades (web).
- `infrastructure` nunca importa `presenters`.
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web
no depende de la API (y viceversa).
@@ -103,6 +112,9 @@ sequenceDiagram
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
- **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una
restricción aparte (la clave, el proyecto, proyecto + nombre). En `events`, el orden global con el que se reenvían los
eventos va en `seq` (`bigserial`), porque un uuid no se ordena; `stream_id` es el id del agregado.
### De escritura a lectura: Redis
@@ -122,6 +134,13 @@ sequenceDiagram
que reentregar un evento no tiene efecto.
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
`domain/read-models`.
- **Esquemas**: clases con `@Schema()` y `@Prop()` de `@nestjs/mongoose` (`read-model.schemas.ts`,
`user.schema.ts`), en `strict: false`: cada documento guarda el read model entero y las props declaradas son las que
tienen índice o una forma que conviene fijar.
- **Ids**: cada documento tiene `_id` (el `ObjectId` de Mongo, que no sale de la base de datos) e `id` con índice
único, que es el id del dominio: el mismo uuid que el `stream_id` del agregado en Postgres. Todas las consultas van
por `id`. En usuarios es el id de Pireon; en actividad, historial y avisos, uno compuesto que hace idempotente la
proyección.
### Tiempo real

View File

@@ -65,3 +65,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- Paquete inicial de la web de 2,13 MB a 1,88 MB con `@pi/ui` 0.0.4, que ya no registra Chart.js al importarse ni
depende de `lodash`.
- Descripciones y comentarios con editor visual (guardado en Markdown), con menciones y capturas pegadas.
- Ids: `uuid` en todas las tablas de Postgres (el orden de los eventos en `seq`) y, en Mongo, `id` con el uuid del
dominio más el `_id` `ObjectId` propio.
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.

Binary file not shown.

Binary file not shown.

View File

@@ -16,20 +16,20 @@ export class MongoUserRegistry extends UserRegistry {
async upsert(user: User): Promise<void> {
await this.users.updateOne(
{ _id: user.id },
{ id: user.id },
{ $set: { name: user.name, email: user.email, isActive: user.isActive } },
{ upsert: true },
);
}
async findById(id: string): Promise<User | null> {
const doc = await this.users.findById(id).lean();
const doc = await this.users.findOne({ id }).lean();
return doc ? this.toUser(doc) : null;
}
async findByIds(ids: string[]): Promise<User[]> {
if (ids.length === 0) return [];
const docs = await this.users.find({ _id: { $in: ids } }).lean();
const docs = await this.users.find({ id: { $in: ids } }).lean();
return docs.map((d) => this.toUser(d));
}
@@ -48,7 +48,7 @@ export class MongoUserRegistry extends UserRegistry {
private toUser(doc: UserDocument): User {
return this.factory.restore({
id: doc._id,
id: doc.id,
name: doc.name,
email: doc.email,
isActive: doc.isActive,

View File

@@ -2,8 +2,9 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema({ collection: 'users', timestamps: true, versionKey: false })
export class UserDocument {
@Prop({ type: String, required: true })
_id!: string;
/** The user's id at Pireon; `_id` is Mongo's own ObjectId. */
@Prop({ type: String, required: true, unique: true })
id!: string;
@Prop({ required: true })
name!: string;

View File

@@ -2,6 +2,7 @@ import {
Column,
CreateDateColumn,
Entity,
Generated,
Index,
PrimaryGeneratedColumn,
Unique,
@@ -13,12 +14,21 @@ import {
*/
@Entity('events')
@Unique('uq_events_stream_position', ['streamId', 'position'])
@Index('idx_events_unpublished', ['id'], { where: '"published_at" IS NULL' })
@Index('idx_events_unpublished', ['seq'], { where: '"published_at" IS NULL' })
export class EventEntity {
/** Global order of the store, used to relay events in sequence. */
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
/** The event's own id. */
@PrimaryGeneratedColumn('uuid')
id!: string;
/**
* Global order of the store, used to relay events in sequence. A uuid has
* no reliable order, so it is kept apart.
*/
@Index('uq_events_seq', { unique: true })
@Column({ type: 'bigint' })
@Generated('increment')
seq!: string;
@Column({ name: 'stream_id', length: 64 })
streamId!: string;

View File

@@ -73,7 +73,7 @@ export class EventStoreRelay
.getRepository(EventEntity)
.createQueryBuilder('e')
.where('e.published_at IS NULL')
.orderBy('e.id', 'ASC')
.orderBy('e.seq', 'ASC')
.limit(BATCH_SIZE)
.setLock('pessimistic_write')
.setOnLocked('skip_locked')

View File

@@ -8,6 +8,8 @@ export * from './lib/releases.service';
export * from './lib/changes.service';
export * from './lib/workflows.service';
export * from './lib/blockers.service';
export * from './lib/bulk-change';
export * from './lib/proposals';
export * from './lib/proposal-quota.service';
export * from './lib/notifications.service';
export * from './lib/notifications';

View File

@@ -5,6 +5,7 @@ import {
CreateAutomationCommand,
DeleteAutomationCommand,
GetAutomationsQuery,
RunAutomationCommand,
UpdateAutomationCommand,
} from './automations/automations';
import { Automation } from './ports/automations.repository';
@@ -41,6 +42,13 @@ export class AutomationsService {
);
}
/** Runs a rule on a ticket; the error, if any, as text. */
run(automation: Automation, issueId: string): Promise<string | null> {
return this.commandBus.execute(
new RunAutomationCommand(automation, issueId),
);
}
delete(actorId: string, automationId: string): Promise<void> {
return this.commandBus.execute(
new DeleteAutomationCommand(actorId, automationId),

View File

@@ -0,0 +1,66 @@
import { Logger } from '@nestjs/common';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
CommentAddedEvent,
Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssueStatusChangedEvent,
SYSTEM_ACTOR_ID,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { AutomationsService } from '../automations.service';
import { WriteAccess } from '../commands/write-access';
import { AutomationsRepository } from '../ports/automations.repository';
import { ruleApplies, triggerOf } from './automation-rules';
/**
* Runs the project's rules on what people do. What rules themselves do (as
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
* per event, also when the event is delivered again.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueAssignedEvent,
CommentAddedEvent,
IssueDevLinkRecordedEvent,
)
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly logger = new Logger(AutomationsEventHandler.name);
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
private readonly service: AutomationsService,
) {}
async handle(event: TrackerEvent) {
// Git reports come as the system too, and a merge is worth reacting to.
if (
event.actorId === SYSTEM_ACTOR_ID &&
!(event instanceof IssueDevLinkRecordedEvent)
)
return;
const trigger = triggerOf(event);
if (!trigger) return;
const rules = await this.automations.findActive(event.projectId);
if (!rules.length) return;
const issue = await this.access.live(trigger.issueId, Issue);
if (!issue) return;
for (const rule of rules) {
if (!ruleApplies(rule, trigger, issue)) continue;
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
if (!(await this.automations.claimRun(rule.id, key))) continue;
let error: string | null;
try {
error = await this.service.run(rule, trigger.issueId);
} catch (failure) {
error = failure instanceof Error ? failure.message : String(failure);
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
}
await this.automations.recordRun(rule.id, error);
}
}
}

View File

@@ -1,13 +1,9 @@
import { randomUUID } from 'node:crypto';
import { Logger } from '@nestjs/common';
import {
Command,
CommandBus,
CommandHandler,
EventPublisher,
EventsHandler,
ICommandHandler,
IEventHandler,
IQueryHandler,
Query,
QueryHandler,
@@ -17,17 +13,11 @@ import {
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
CommentAddedEvent,
CommentFactory,
Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssuePriority,
IssueStatusChangedEvent,
Project,
SYSTEM_ACTOR_ID,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import {
@@ -42,8 +32,6 @@ import {
AutomationConditions,
AutomationTrigger,
normalizeRule,
ruleApplies,
triggerOf,
} from './automation-rules';
/** A project keeps at most this many rules. */
@@ -314,63 +302,10 @@ export class GetAutomationsQueryHandler
}
}
/**
* Runs the project's rules on what people do. What rules themselves do (as
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
* per event, also when the event is delivered again.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueAssignedEvent,
CommentAddedEvent,
IssueDevLinkRecordedEvent,
)
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly logger = new Logger(AutomationsEventHandler.name);
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async handle(event: TrackerEvent) {
// Git reports come as the system too, and a merge is worth reacting to.
if (
event.actorId === SYSTEM_ACTOR_ID &&
!(event instanceof IssueDevLinkRecordedEvent)
)
return;
const trigger = triggerOf(event);
if (!trigger) return;
const rules = await this.automations.findActive(event.projectId);
if (!rules.length) return;
const issue = await this.access.live(trigger.issueId, Issue);
if (!issue) return;
for (const rule of rules) {
if (!ruleApplies(rule, trigger, issue)) continue;
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
if (!(await this.automations.claimRun(rule.id, key))) continue;
let error: string | null;
try {
error = await this.commandBus.execute(
new RunAutomationCommand(rule, trigger.issueId),
);
} catch (failure) {
error = failure instanceof Error ? failure.message : String(failure);
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
}
await this.automations.recordRun(rule.id, error);
}
}
}
export const AUTOMATION_HANDLERS = [
CreateAutomationCommandHandler,
UpdateAutomationCommandHandler,
DeleteAutomationCommandHandler,
RunAutomationCommandHandler,
GetAutomationsQueryHandler,
AutomationsEventHandler,
];

View File

@@ -0,0 +1,23 @@
import { IssuePriority } from '@task-stream/api/tracker/domain';
/** At most this many tickets per bulk change. */
export const MAX_BULK = 100;
/** What to change on every ticket; absent fields stay as they are. */
export interface BulkChange {
status?: string;
/** null unassigns. */
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
/** null: out of its release. */
releaseId?: string | null;
priority?: IssuePriority;
/** Added to the ticket's labels. */
addLabels?: string[];
}
export interface BulkResult {
updated: number;
results: { issueId: string; ok: boolean; error: string | null }[];
}

View File

@@ -1,115 +0,0 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import { IssuePriority } from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './assign-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { SetIssueSprintCommand } from './sprint.commands';
import { UpdateIssueCommand } from './update-issue.command';
import { WriteAccess } from './write-access';
/** At most this many tickets per bulk change. */
export const MAX_BULK = 100;
/** What to change on every ticket; absent fields stay as they are. */
export interface BulkChange {
status?: string;
/** null unassigns. */
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
/** null: out of its release. */
releaseId?: string | null;
priority?: IssuePriority;
/** Added to the ticket's labels. */
addLabels?: string[];
}
export interface BulkResult {
updated: number;
results: { issueId: string; ok: boolean; error: string | null }[];
}
/**
* The same change on several tickets. Each ticket goes through the normal
* commands (permissions, workflow, open sprints and releases), one after
* another; one refusing does not stop the rest.
*/
export class BulkUpdateIssuesCommand extends Command<BulkResult> {
constructor(
public readonly actorId: string,
public readonly issueIds: string[],
public readonly change: BulkChange,
) {
super();
}
}
@CommandHandler(BulkUpdateIssuesCommand)
export class BulkUpdateIssuesCommandHandler
implements ICommandHandler<BulkUpdateIssuesCommand>
{
constructor(
private readonly commandBus: CommandBus,
private readonly access: WriteAccess,
) {}
async execute(command: BulkUpdateIssuesCommand): Promise<BulkResult> {
const ids = [...new Set(command.issueIds)];
assertThat(ids.length > 0, 'Pick at least one ticket');
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
const change = command.change;
assertThat(
Object.values(change).some((v) => v !== undefined),
'Nothing to change',
);
const actor = command.actorId;
const result: BulkResult = { updated: 0, results: [] };
for (const issueId of ids) {
try {
if (change.status !== undefined)
await this.run(
new ChangeIssueStatusCommand(actor, issueId, change.status),
);
if (change.assigneeId !== undefined)
await this.run(
new AssignIssueCommand(actor, issueId, change.assigneeId),
);
if (change.sprintId !== undefined)
await this.run(
new SetIssueSprintCommand(actor, issueId, change.sprintId),
);
if (change.releaseId !== undefined)
await this.run(
new SetIssueReleaseCommand(actor, issueId, change.releaseId),
);
const details: { priority?: IssuePriority; labels?: string[] } = {};
if (change.priority) details.priority = change.priority;
if (change.addLabels?.length) {
const issue = await this.access.issue(issueId);
details.labels = [...issue.labels, ...change.addLabels];
}
if (Object.keys(details).length)
await this.run(new UpdateIssueCommand(actor, issueId, details));
result.updated++;
result.results.push({ issueId, ok: true, error: null });
} catch (error) {
result.results.push({
issueId,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
private run(command: object): Promise<unknown> {
return this.commandBus.execute(command as never);
}
}

View File

@@ -1,7 +1,5 @@
import { DuplicateIssueCommandHandler } from './duplicate-issue.command-handler';
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { SubmitProposalCommandHandler } from './submit-proposal.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler';
@@ -36,7 +34,6 @@ import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler
import { RankIssueCommandHandler } from './rank-issue.command-handler';
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
import { TRASH_HANDLERS } from './trash';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import {
@@ -46,10 +43,8 @@ import {
} from './change-checklist.command-handler';
export const COMMAND_HANDLERS = [
SubmitProposalCommandHandler,
VoteIssueCommandHandler,
ReactToCommentCommandHandler,
DuplicateIssueCommandHandler,
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,
@@ -85,7 +80,6 @@ export const COMMAND_HANDLERS = [
RankIssueCommandHandler,
WriteReleaseNotesCommandHandler,
...SPRINT_COMMAND_HANDLERS,
BulkUpdateIssuesCommandHandler,
...TRASH_HANDLERS,
AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,

View File

@@ -75,10 +75,8 @@ import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { UserDirectory } from '../ports/user-directory';
import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command';
import { DuplicateIssueCommand } from './duplicate-issue.command';
import { ReactToCommentCommand } from './react-to-comment.command';
import { UpdateProjectCommand } from './update-project.command';
import { SubmitProposalCommand } from './submit-proposal.command';
import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
@@ -87,6 +85,8 @@ import { DetachFileCommand } from './detach-file.command';
import { WatchIssueCommand } from './watch-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { IssuesService } from '../issues.service';
import { ProjectsService } from '../projects.service';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { CreateProjectCommand } from './create-project.command';
@@ -96,7 +96,6 @@ import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
import { RestoreIssueCommand, TrashIssueCommand } from './trash';
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
import { ReadAccess } from '../queries/read-access';
import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -131,6 +130,9 @@ async function setup() {
imports: [CqrsModule.forRoot()],
providers: [
...COMMAND_HANDLERS,
// What composes several commands lives in the services.
IssuesService,
ProjectsService,
...GIT_HANDLERS,
RunAutomationCommandHandler,
...TRACKER_FACTORIES,
@@ -196,6 +198,8 @@ async function setup() {
projectViews,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
issues: moduleRef.get(IssuesService),
projects: moduleRef.get(ProjectsService),
};
}
@@ -702,7 +706,7 @@ describe('tracker commands (event sourced)', () => {
});
it('changes several tickets at once and says which refused', async () => {
const { run, rehydrator } = await setup();
const { issues, run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const ids: string[] = [];
for (const title of ['A', 'B'])
@@ -716,13 +720,11 @@ describe('tracker commands (event sourced)', () => {
for (const status of ['TODO', 'IN_PROGRESS'])
await run(new ChangeIssueStatusCommand('dev', ids[1], status));
// A is in BACKLOG: the workflow does not let it jump to review.
const result = (await run(
new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], {
status: 'IN_REVIEW',
assigneeId: 'qa',
addLabels: ['sprint-14'],
}),
)) as unknown as BulkResult;
const result = await issues.bulkUpdate('dev', [...ids, ids[1]], {
status: 'IN_REVIEW',
assigneeId: 'qa',
addLabels: ['sprint-14'],
});
expect(result.updated).toBe(1);
expect(result.results.map((r) => [r.issueId, r.ok])).toEqual([
@@ -735,9 +737,9 @@ describe('tracker commands (event sourced)', () => {
'qa',
['sprint-14'],
]);
await expect(
run(new BulkUpdateIssuesCommand('dev', ids, {})),
).rejects.toThrow(/Nothing to change/);
await expect(issues.bulkUpdate('dev', ids, {})).rejects.toThrow(
/Nothing to change/,
);
});
it('sends tickets to the trash and back; trashed ones cannot change', async () => {
@@ -850,7 +852,7 @@ describe('tracker commands (event sourced)', () => {
});
it('duplicates what a ticket says, not where it stands', async () => {
const { run, rehydrator } = await setup();
const { issues, run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const original = await run(
new CreateIssueCommand(
@@ -869,7 +871,7 @@ describe('tracker commands (event sourced)', () => {
);
await run(new AddChecklistItemCommand('dev', original.id, 'Reproducir'));
const copy = await run(new DuplicateIssueCommand('qa', original.id));
const copy = await issues.duplicate('qa', original.id);
expect(copy.key).not.toBe(original.key);
const issue = await rehydrator.rehydrate(copy.id, Issue);
expect(issue).toMatchObject({
@@ -884,9 +886,9 @@ describe('tracker commands (event sourced)', () => {
expect(issue.checklist.map((s) => [s.text, s.done])).toEqual([
['Reproducir', false],
]);
await expect(
run(new DuplicateIssueCommand('pm', original.id)),
).rejects.toThrow(ForbiddenError);
await expect(issues.duplicate('pm', original.id)).rejects.toThrow(
ForbiddenError,
);
});
it('takes reactions from members, once each, viewers included', async () => {
@@ -939,7 +941,7 @@ describe('tracker commands (event sourced)', () => {
});
it('turns a proposal into a ticket, only where the project takes them', async () => {
const { run, rehydrator, projectViews } = await setup();
const { run, rehydrator, projectViews, projects } = await setup();
const projectId = await projectWithTeam(run);
const view = {
id: projectId,
@@ -949,14 +951,12 @@ describe('tracker commands (event sourced)', () => {
};
projectViews.set(projectId, view);
const propose = () =>
run(
new SubmitProposalCommand(
'ts',
'BUG',
'No carga en Firefox',
'Pantalla en blanco',
'Lucía',
),
projects.propose(
'ts',
'BUG',
'No carga en Firefox',
'Pantalla en blanco',
'Lucía',
);
await expect(propose()).rejects.toThrow(ForbiddenError);

View File

@@ -1,61 +0,0 @@
import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { AddChecklistItemCommand } from './change-checklist.command';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { DuplicateIssueCommand } from './duplicate-issue.command';
import { WriteAccess } from './write-access';
/**
* Copies what a ticket says (type, title, description, area, priority,
* labels, Figma, estimate, dates, parent and checklist steps, unticked),
* not where it stands: the copy starts in the first status, unassigned,
* out of sprints and releases, without comments or files. Created through
* the usual command, so the same rules and events apply.
*/
@CommandHandler(DuplicateIssueCommand)
export class DuplicateIssueCommandHandler
implements ICommandHandler<DuplicateIssueCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async execute(command: DuplicateIssueCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
const copy: CommandResult = await this.commandBus.execute(
new CreateIssueCommand(
command.actorId,
issue.projectId,
issue.type,
`${issue.title} (copia)`.slice(0, 255),
{
description: issue.description,
area: issue.area,
priority: issue.priority,
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
startDate: issue.startDate,
dueDate: issue.dueDate,
},
null,
null,
issue.parentId,
null,
// The original's text is the content, not the type's template.
false,
),
);
let result = copy;
for (const step of issue.checklist) {
result = await this.commandBus.execute(
new AddChecklistItemCommand(command.actorId, copy.id, step.text),
);
}
return { ...result, key: copy.key };
}
}

View File

@@ -1,12 +0,0 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** A new ticket like this one, to start from: the copy's key in the result. */
export class DuplicateIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
) {
super();
}
}

View File

@@ -6,11 +6,8 @@ export * from './assign-issue.command';
export * from './change-issue-status.command';
export * from './change-project-member-role.command';
export * from './create-issue.command';
export * from './duplicate-issue.command';
export * from './react-to-comment.command';
export * from './vote-issue.command';
export * from './submit-proposal.command';
export { PROPOSAL_LABEL } from './submit-proposal.command-handler';
export * from './create-project.command';
export * from './create-release.command';
export * from './delete-comment.command';
@@ -40,5 +37,4 @@ export * from './change-checklist.command';
export * from './rank-issue.command';
export * from './sprint.commands';
export * from './write-release-notes.command';
export * from './bulk-update-issues';
export * from './trash';

View File

@@ -1,82 +0,0 @@
import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
ForbiddenError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
IssuePriority,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { ProposalKind, SubmitProposalCommand } from './submit-proposal.command';
import { WriteAccess } from './write-access';
/** The label every proposal carries, for the team to find and sort them. */
export const PROPOSAL_LABEL = 'propuesta';
/** Preferred ticket type of each kind, when the project has it active. */
const TYPE_OF: Record<ProposalKind, string> = { BUG: 'BUG', IDEA: 'FEATURE' };
/**
* Only a public project that takes proposals accepts one. It is opened by
* TaskStream (nobody signed in), in the first status, as a Bug or a Feature
* (or the first active type if the project has neither), and signed with
* the name they gave, if any.
*/
@CommandHandler(SubmitProposalCommand)
export class SubmitProposalCommandHandler
implements ICommandHandler<SubmitProposalCommand>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async execute(command: SubmitProposalCommand): Promise<CommandResult> {
const found =
(await this.findProjects.findById(command.project)) ??
(await this.findProjects.findByKey(command.project.toUpperCase()));
if (!found?.isPublic) throw new NotFoundError('Project', command.project);
if (!found.acceptsProposals)
throw new ForbiddenError(`${found.key} does not take proposals`);
const title = command.title.trim();
assertThat(
title.length >= 3 && title.length <= 150,
'A proposal needs a title of 3 to 150 characters',
);
const text = command.description.trim();
assertThat(text.length <= 5000, 'A proposal is 5000 characters at most');
const project = await this.access.project(found.id);
const active = project.settings.issueTypes.filter((t) => !t.archived);
const type =
active.find((t) => t.id === TYPE_OF[command.kind])?.id ?? active[0]?.id;
const author = command.author?.trim().slice(0, 80);
const signature = `_Propuesta enviada desde el formulario público${author ? ` por ${author}` : ''}._`;
return this.commandBus.execute(
new CreateIssueCommand(
SYSTEM_ACTOR_ID,
project.id,
type,
title,
{
description: text ? `${text}\n\n${signature}` : signature,
labels: [PROPOSAL_LABEL],
priority: IssuePriority.MEDIUM,
},
null,
null,
null,
null,
// What they wrote is the content, not the type's template.
false,
),
);
}
}

View File

@@ -1,22 +0,0 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export type ProposalKind = 'BUG' | 'IDEA';
/**
* A bug report or an idea from anyone, through a public project's form:
* it becomes a ticket labelled `propuesta`, for the team to look at.
*/
export class SubmitProposalCommand extends Command<CommandResult> {
constructor(
/** Project id or key. */
public readonly project: string,
public readonly kind: ProposalKind,
public readonly title: string,
public readonly description: string,
/** How they sign it, if they want to: shown in the ticket. */
public readonly author: string | null,
) {
super();
}
}

View File

@@ -1,13 +1,5 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { DeleteIssueCommand } from './delete-issue.command';
import { WriteAccess } from './write-access';
/** Days a ticket stays in the trash before it is deleted for good. */
@@ -31,13 +23,6 @@ export class RestoreIssueCommand extends Command<CommandResult> {
}
}
/** Scheduler only: deletes for good what went to the trash before `before`. */
export class EmptyTrashCommand extends Command<number> {
constructor(public readonly before: string) {
super();
}
}
@CommandHandler(TrashIssueCommand)
export class TrashIssueCommandHandler
implements ICommandHandler<TrashIssueCommand>
@@ -73,31 +58,7 @@ export class RestoreIssueCommandHandler
}
}
/**
* Each deletion is the usual one: comments purged, links dropped and
* subtasks back to the top, by the sagas.
*/
@CommandHandler(EmptyTrashCommand)
export class EmptyTrashCommandHandler
implements ICommandHandler<EmptyTrashCommand>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly commandBus: CommandBus,
) {}
async execute(command: EmptyTrashCommand): Promise<number> {
const old = await this.findIssues.findTrashedBefore(command.before);
for (const issue of old)
await this.commandBus.execute(
new DeleteIssueCommand(SYSTEM_ACTOR_ID, issue.id),
);
return old.length;
}
}
export const TRASH_HANDLERS = [
TrashIssueCommandHandler,
RestoreIssueCommandHandler,
EmptyTrashCommandHandler,
];

View File

@@ -1,14 +1,17 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import {
ActivityReadModel,
EpicReadModel,
IssueDetails,
IssueLinkType,
IssuePriority,
IssueReadModel,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
import { AssignIssueCommand } from './commands/assign-issue.command';
import { DuplicateIssueCommand } from './commands/duplicate-issue.command';
import { VoteIssueCommand } from './commands/vote-issue.command';
import {
AttachFileCommand,
@@ -19,13 +22,12 @@ import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command';
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import { RankIssueCommand } from './commands/rank-issue.command';
import { DeleteIssueCommand } from './commands/delete-issue.command';
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash';
import { SetIssueSprintCommand } from './commands/sprint.commands';
import { WriteAccess } from './commands/write-access';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { GetTrashQuery } from './queries/get-trash.query';
import {
BulkChange,
BulkResult,
BulkUpdateIssuesCommand,
} from './commands/bulk-update-issues';
import {
AddChecklistItemCommand,
RemoveChecklistItemCommand,
@@ -62,6 +64,10 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
parentId?: string | null;
/** Imports only: start in this (active) status instead of the first one. */
initialStatus?: string | null;
/** Fill in the type's template (false when the content comes with it). */
useTemplate?: boolean;
}
export interface IssueLinkInput {
@@ -75,6 +81,8 @@ export class IssuesService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {}
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
@@ -85,6 +93,8 @@ export class IssuesService {
assigneeId,
releaseId,
parentId,
initialStatus,
useTemplate,
...details
} = input;
return this.commandBus.execute(
@@ -97,6 +107,8 @@ export class IssuesService {
assigneeId,
releaseId,
parentId,
initialStatus,
useTemplate ?? true,
),
);
}
@@ -241,15 +253,55 @@ export class IssuesService {
);
}
/** The same change on several tickets; each one may succeed or not. */
bulkUpdate(
/**
* The same change on several tickets. Each ticket goes through the normal
* commands (permissions, workflow, open sprints and releases), one after
* another; one refusing does not stop the rest.
*/
async bulkUpdate(
actorId: string,
issueIds: string[],
change: BulkChange,
): Promise<BulkResult> {
return this.commandBus.execute(
new BulkUpdateIssuesCommand(actorId, issueIds, change),
const ids = [...new Set(issueIds)];
assertThat(ids.length > 0, 'Pick at least one ticket');
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
assertThat(
Object.values(change).some((v) => v !== undefined),
'Nothing to change',
);
const result: BulkResult = { updated: 0, results: [] };
for (const issueId of ids) {
try {
if (change.status !== undefined)
await this.changeStatus(actorId, issueId, change.status);
if (change.assigneeId !== undefined)
await this.assign(actorId, issueId, change.assigneeId);
if (change.sprintId !== undefined)
await this.commandBus.execute(
new SetIssueSprintCommand(actorId, issueId, change.sprintId),
);
if (change.releaseId !== undefined)
await this.setRelease(actorId, issueId, change.releaseId);
const details: { priority?: IssuePriority; labels?: string[] } = {};
if (change.priority) details.priority = change.priority;
if (change.addLabels?.length) {
const issue = await this.access.issue(issueId);
details.labels = [...issue.labels, ...change.addLabels];
}
if (Object.keys(details).length)
await this.update(actorId, issueId, details);
result.updated++;
result.results.push({ issueId, ok: true, error: null });
} catch (error) {
result.results.push({
issueId,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/** To the trash (whoever opened it, or a manager); back with `restore`. */
@@ -257,6 +309,22 @@ export class IssuesService {
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
}
/** Deletes it for good (the trash is emptied this way). */
delete(actorId: string, issueId: string): Promise<void> {
return this.commandBus.execute(new DeleteIssueCommand(actorId, issueId));
}
/**
* Deletes for good what went to the trash before `before`; how many. Each
* deletion is the usual one: comments purged, links dropped and subtasks
* back to the top, by the sagas.
*/
async emptyTrash(before: string): Promise<number> {
const old = await this.findIssues.findTrashedBefore(before);
for (const issue of old) await this.delete(SYSTEM_ACTOR_ID, issue.id);
return old.length;
}
restore(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
}
@@ -266,9 +334,35 @@ export class IssuesService {
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on));
}
/** A new ticket like this one (what it says, not where it stands). */
duplicate(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new DuplicateIssueCommand(actorId, issueId));
/**
* A new ticket like this one: what it says (type, title, description,
* area, priority, labels, Figma, estimate, dates, parent and checklist
* steps, unticked), not where it stands. The copy starts in the first
* status, unassigned, out of sprints and releases, without comments or
* files; the copy's key comes in the result.
*/
async duplicate(actorId: string, issueId: string): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(issueId, actorId);
const copy = await this.create(actorId, {
projectId: issue.projectId,
type: issue.type,
title: `${issue.title} (copia)`.slice(0, 255),
description: issue.description,
area: issue.area,
priority: issue.priority,
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
startDate: issue.startDate,
dueDate: issue.dueDate,
parentId: issue.parentId,
// The original's text is the content, not the type's template.
useTemplate: false,
});
let result = copy;
for (const step of issue.checklist)
result = await this.addChecklistItem(actorId, copy.id, step.text);
return { ...result, key: copy.key };
}
trashed(

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { SendDueRemindersCommand } from './notifications/reminders';
import {
MarkNotificationsReadCommand,
UpdateNotificationSettingsCommand,
@@ -36,6 +37,11 @@ export class NotificationsService {
}
/** `ids` omitted: everything. */
/** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */
sendDueReminders(day: string): Promise<number> {
return this.commandBus.execute(new SendDueRemindersCommand(day));
}
markRead(userId: string, ids?: string[] | null): Promise<void> {
return this.commandBus.execute(
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),

View File

@@ -16,7 +16,7 @@ import {
TrackerEvent,
withoutMentionMarkup,
} from '@task-stream/api/tracker/domain';
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
import { PROPOSAL_LABEL } from '../proposals';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';

View File

@@ -1,18 +1,25 @@
import {
ProposalKind,
SubmitProposalCommand,
} from './commands/submit-proposal.command';
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ForbiddenError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
IssuePriority,
ProjectAnalyticsReadModel,
ProjectReadModel,
ProjectRole,
ProjectSettings,
ProjectStatsReadModel,
ReleaseReadModel,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { CreateIssueCommand } from './commands/create-issue.command';
import { WriteAccess } from './commands/write-access';
import { FindProjectsRepository } from './ports/find-projects.repository';
import { PROPOSAL_LABEL, PROPOSAL_TYPE, ProposalKind } from './proposals';
import { AddProjectMemberCommand } from './commands/add-project-member.command';
import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command';
import { CommandResult } from './commands/command-result';
@@ -35,22 +42,68 @@ export class ProjectsService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository,
private readonly access: WriteAccess,
) {}
/** A public project's roadmap, releases, sprint and good first tickets (no token). */
/** A bug report or an idea from anyone, for a public project that takes them. */
propose(
project: string,
/**
* A bug report or an idea from anyone, for a public project that takes
* them. It becomes a ticket labelled «propuesta», opened by TaskStream
* (nobody signed in) in the first status, as a Bug or a Feature (or the
* first active type if the project has neither), signed with the name
* they gave, if any.
*/
async propose(
projectIdOrKey: string,
kind: ProposalKind,
title: string,
description: string,
author: string | null,
): Promise<CommandResult> {
const found =
(await this.findProjects.findById(projectIdOrKey)) ??
(await this.findProjects.findByKey(projectIdOrKey.toUpperCase()));
if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey);
if (!found.acceptsProposals)
throw new ForbiddenError(`${found.key} does not take proposals`);
const cleanTitle = title.trim();
assertThat(
cleanTitle.length >= 3 && cleanTitle.length <= 150,
'A proposal needs a title of 3 to 150 characters',
);
const text = description.trim();
assertThat(text.length <= 5000, 'A proposal is 5000 characters at most');
const project = await this.access.project(found.id);
const active = project.settings.issueTypes.filter((t) => !t.archived);
const type =
active.find((t) => t.id === PROPOSAL_TYPE[kind])?.id ?? active[0]?.id;
const signer = author?.trim().slice(0, 80);
const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`;
return this.commandBus.execute(
new SubmitProposalCommand(project, kind, title, description, author),
new CreateIssueCommand(
SYSTEM_ACTOR_ID,
project.id,
type,
cleanTitle,
{
description: text ? `${text}\n\n${signature}` : signature,
labels: [PROPOSAL_LABEL],
priority: IssuePriority.MEDIUM,
},
null,
null,
null,
null,
// What they wrote is the content, not the type's template.
false,
),
);
}
/** A public project's roadmap, releases, sprint and good first tickets (no token). */
showcase(project: string): Promise<Showcase> {
return this.queryBus.execute(new GetShowcaseQuery(project));
}

View File

@@ -0,0 +1,10 @@
export type ProposalKind = 'BUG' | 'IDEA';
/** The label every proposal carries, for the team to find and sort them. */
export const PROPOSAL_LABEL = 'propuesta';
/** Preferred ticket type of each kind, when the project has it active. */
export const PROPOSAL_TYPE: Record<ProposalKind, string> = {
BUG: 'BUG',
IDEA: 'FEATURE',
};

View File

@@ -6,7 +6,7 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
import { PROPOSAL_LABEL } from '../proposals';
/** Tickets with this label are offered to newcomers on public pages. */
export const GOOD_FIRST_LABEL = 'good-first-issue';

View File

@@ -43,6 +43,7 @@ import { TransferService } from './transfer.service';
import { TRANSFER_HANDLERS } from './transfer/import-export';
import { AutomationsService } from './automations.service';
import { AUTOMATION_HANDLERS } from './automations/automations';
import { AutomationsEventHandler } from './automations/automations.event-handler';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
@@ -97,6 +98,7 @@ const SERVICES = [
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,
...AUTOMATION_HANDLERS,
AutomationsEventHandler,
],
exports: SERVICES,
})

View File

@@ -1,31 +1,115 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { QueryBus } from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { CommandResult } from './commands/command-result';
import { WriteAccess } from './commands/write-access';
import { IssuesService } from './issues.service';
import { FindProjectsRepository } from './ports/find-projects.repository';
import {
ExportIssuesQuery,
ImportIssuesCommand,
ImportReport,
MAX_IMPORT_MB,
} from './transfer/import-export';
import { planImport } from './transfer/transfer';
/** Tickets in and out as CSV (TaskStream's own, or a Jira export). */
@Injectable()
export class TransferService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly issues: IssuesService,
) {}
exportCsv(viewerId: string | null, projectId: string): Promise<string> {
return this.queryBus.execute(new ExportIssuesQuery(viewerId, projectId));
}
importCsv(
/**
* Creates the tickets of a CSV one by one (each a normal ticket creation,
* with its own events), then places them under their parents. Owners and
* admins. With `dryRun`, only tells what would happen.
*/
async importCsv(
actorId: string,
projectId: string,
csv: string,
dryRun: boolean,
): Promise<ImportReport> {
return this.commandBus.execute(
new ImportIssuesCommand(actorId, projectId, csv, dryRun),
);
(await this.access.project(projectId)).assertCanManage(actorId);
if (Buffer.byteLength(csv) > MAX_IMPORT_MB * 1024 * 1024)
throw new BusinessRuleError(
`The file is larger than ${MAX_IMPORT_MB} MB`,
);
const project = await this.findProjects.findById(projectId);
if (!project) throw new BusinessRuleError('The project is not ready yet');
const plan = planImport(csv, project);
const report: ImportReport = {
format: plan.format,
dryRun: dryRun,
rows: plan.rows.length,
created: 0,
keys: [],
skipped: [...plan.skipped],
warnings: [...plan.warnings],
};
if (dryRun) return report;
const created = new Map<string, string>();
const ids = new Map<number, string>();
for (const row of plan.rows) {
try {
const result: CommandResult = await this.issues.create(actorId, {
projectId: project.id,
type: row.type,
title: row.title,
description: row.description,
priority: row.priority,
labels: row.labels,
storyPoints: row.storyPoints,
startDate: row.startDate,
dueDate: row.dueDate,
assigneeId: row.assigneeId,
initialStatus: row.status,
// The file brings the content, not the type's template.
useTemplate: false,
});
report.created++;
report.keys.push(result.key ?? result.id);
ids.set(row.line, result.id);
for (const key of row.sourceKeys) created.set(key, result.id);
if (result.key) created.set(result.key, result.id);
} catch (error) {
report.skipped.push({
line: row.line,
reason: error instanceof Error ? error.message : String(error),
});
}
}
// Parents last: they may come after their children in the file.
for (const row of plan.rows) {
const id = ids.get(row.line);
const parent = row.parentSource
? created.get(row.parentSource)
: undefined;
if (!id || !row.parentSource) continue;
if (!parent) {
report.warnings.push({
line: row.line,
message: `Su padre (${row.parentSource}) no está en el archivo`,
});
continue;
}
try {
await this.issues.setParent(actorId, id, parent);
} catch (error) {
report.warnings.push({
line: row.line,
message: `No cuelga de ${row.parentSource}: ${error instanceof Error ? error.message : error}`,
});
}
}
return report;
}
}

View File

@@ -1,25 +1,12 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { CommandResult } from '../commands/command-result';
import { CreateIssueCommand } from '../commands/create-issue.command';
import { SetIssueParentCommand } from '../commands/set-issue-parent.command';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from '../queries/read-access';
import { ImportFormat, exportIssuesCsv, planImport } from './transfer';
import { ImportFormat, exportIssuesCsv } from './transfer';
/** A file is at most this many megabytes. */
export const MAX_IMPORT_MB = 5;
@@ -36,18 +23,6 @@ export interface ImportReport {
warnings: { line: number; message: string }[];
}
export class ImportIssuesCommand extends Command<ImportReport> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly csv: string,
/** Plan only: tell what would happen and create nothing. */
public readonly dryRun: boolean,
) {
super();
}
}
export class ExportIssuesQuery extends Query<string> {
constructor(
public readonly viewerId: string | null,
@@ -57,108 +32,6 @@ export class ExportIssuesQuery extends Query<string> {
}
}
/**
* Creates the tickets of a CSV one by one (each a normal ticket creation, with
* its own events), then places them under their parents. Owners and admins.
*/
@CommandHandler(ImportIssuesCommand)
export class ImportIssuesCommandHandler
implements ICommandHandler<ImportIssuesCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly commandBus: CommandBus,
) {}
async execute(command: ImportIssuesCommand): Promise<ImportReport> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
if (Buffer.byteLength(command.csv) > MAX_IMPORT_MB * 1024 * 1024)
throw new BusinessRuleError(
`The file is larger than ${MAX_IMPORT_MB} MB`,
);
const project = await this.findProjects.findById(command.projectId);
if (!project) throw new BusinessRuleError('The project is not ready yet');
const plan = planImport(command.csv, project);
const report: ImportReport = {
format: plan.format,
dryRun: command.dryRun,
rows: plan.rows.length,
created: 0,
keys: [],
skipped: [...plan.skipped],
warnings: [...plan.warnings],
};
if (command.dryRun) return report;
const created = new Map<string, string>();
const ids = new Map<number, string>();
for (const row of plan.rows) {
try {
const result: CommandResult = await this.commandBus.execute(
new CreateIssueCommand(
command.actorId,
project.id,
row.type,
row.title,
{
description: row.description,
priority: row.priority,
labels: row.labels,
storyPoints: row.storyPoints,
startDate: row.startDate,
dueDate: row.dueDate,
},
row.assigneeId,
null,
null,
row.status,
false,
),
);
report.created++;
report.keys.push(result.key ?? result.id);
ids.set(row.line, result.id);
for (const key of row.sourceKeys) created.set(key, result.id);
if (result.key) created.set(result.key, result.id);
} catch (error) {
report.skipped.push({
line: row.line,
reason: error instanceof Error ? error.message : String(error),
});
}
}
// Parents last: they may come after their children in the file.
for (const row of plan.rows) {
const id = ids.get(row.line);
const parent = row.parentSource
? created.get(row.parentSource)
: undefined;
if (!id || !row.parentSource) continue;
if (!parent) {
report.warnings.push({
line: row.line,
message: `Su padre (${row.parentSource}) no está en el archivo`,
});
continue;
}
try {
await this.commandBus.execute(
new SetIssueParentCommand(command.actorId, id, parent),
);
} catch (error) {
report.warnings.push({
line: row.line,
message: `No cuelga de ${row.parentSource}: ${error instanceof Error ? error.message : error}`,
});
}
}
return report;
}
}
@QueryHandler(ExportIssuesQuery)
export class ExportIssuesQueryHandler
implements IQueryHandler<ExportIssuesQuery, string>
@@ -182,7 +55,4 @@ export class ExportIssuesQueryHandler
}
}
export const TRANSFER_HANDLERS = [
ImportIssuesCommandHandler,
ExportIssuesQueryHandler,
];
export const TRANSFER_HANDLERS = [ExportIssuesQueryHandler];

View File

@@ -3,11 +3,7 @@ import {
SYSTEM_ACTOR_NAME,
} from '@task-stream/api/tracker/domain';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
FindUserQuery,
FindUsersByIdsQuery,
} from '@task-stream/api/identity/application';
import { IdentityService } from '@task-stream/api/identity/application';
import {
DirectoryUser,
UserContact,
@@ -15,10 +11,13 @@ import {
UserRefs,
} from '@task-stream/api/tracker/application';
/** Anti-corruption layer: the tracker asks the identity context about users. */
/**
* Anti-corruption layer: the tracker asks the identity context about users,
* through its service (the identity module is global for this).
*/
@Injectable()
export class IdentityUserDirectory extends UserDirectory {
constructor(private readonly queryBus: QueryBus) {
constructor(private readonly identity: IdentityService) {
super();
}
@@ -26,9 +25,7 @@ export class IdentityUserDirectory extends UserDirectory {
userId: string,
accessToken?: string,
): Promise<DirectoryUser | null> {
const user = await this.queryBus.execute(
new FindUserQuery(userId, accessToken),
);
const user = await this.identity.findUser(userId, accessToken);
return user
? { id: user.id, name: user.name, isActive: user.isActive }
: null;
@@ -37,7 +34,7 @@ export class IdentityUserDirectory extends UserDirectory {
async contacts(userIds: string[]): Promise<UserContact[]> {
const wanted = [...new Set(userIds.filter(Boolean))];
if (!wanted.length) return [];
const users = await this.queryBus.execute(new FindUsersByIdsQuery(wanted));
const users = await this.identity.findUsers(wanted);
return users.map((u) => ({
id: u.id,
name: u.name,
@@ -47,9 +44,7 @@ export class IdentityUserDirectory extends UserDirectory {
async refs(userIds: (string | null | undefined)[]): Promise<UserRefs> {
const wanted = [...new Set(userIds.filter((id): id is string => !!id))];
const users = wanted.length
? await this.queryBus.execute(new FindUsersByIdsQuery(wanted))
: [];
const users = wanted.length ? await this.identity.findUsers(wanted) : [];
const names = new Map(users.map((u) => [u.id, u.name]));
const of = (id: string) => ({
id,

View File

@@ -41,12 +41,14 @@ export class MongoAutomationsRepository extends AutomationsRepository {
}
async findById(id: string): Promise<Automation | null> {
const doc = await this.automations.findById(id).lean<AutomationDoc>();
const doc = await this.automations
.findOne({ id: id })
.lean<AutomationDoc>();
return doc ? (fromDoc(doc) as Automation) : null;
}
async insert({ id, ...rest }: Automation): Promise<void> {
await this.automations.create({ _id: id, ...rest });
await this.automations.create({ id, ...rest });
}
async update(
@@ -54,16 +56,16 @@ export class MongoAutomationsRepository extends AutomationsRepository {
changes: Partial<Pick<Automation, 'name' | 'active'>>,
): Promise<void> {
if (Object.keys(changes).length)
await this.automations.updateOne({ _id: id }, { $set: changes });
await this.automations.updateOne({ id }, { $set: changes });
}
async delete(id: string): Promise<void> {
await this.automations.deleteOne({ _id: id });
await this.automations.deleteOne({ id });
}
async claimRun(ruleId: string, eventKey: string): Promise<boolean> {
try {
await this.runs.create({ _id: `${ruleId}:${eventKey}`, at: new Date() });
await this.runs.create({ id: `${ruleId}:${eventKey}`, at: new Date() });
return true;
} catch (error) {
if ((error as { code?: number }).code === DUPLICATE_KEY) return false;
@@ -73,7 +75,7 @@ export class MongoAutomationsRepository extends AutomationsRepository {
async recordRun(id: string, error: string | null): Promise<void> {
await this.automations.updateOne(
{ _id: id },
{ id },
{
$inc: { runs: 1 },
$set: { lastRunAt: new Date().toISOString(), lastError: error },

View File

@@ -79,7 +79,7 @@ export class MongoFindProjectsRepository extends FindProjectsRepository {
}
async findById(id: string): Promise<ProjectReadModel | null> {
const doc = await this.projects.findById(id).lean<ProjectDoc>();
const doc = await this.projects.findOne({ id: id }).lean<ProjectDoc>();
return doc ? toProject(doc) : null;
}
@@ -109,7 +109,7 @@ export class MongoFindProjectsRepository extends FindProjectsRepository {
userId: string,
): Promise<ProjectRole | null> {
const doc = await this.projects
.findOne({ _id: projectId, memberIds: userId }, { members: 1 })
.findOne({ id: projectId, memberIds: userId }, { members: 1 })
.lean<Pick<ProjectDoc, 'members'>>();
return doc?.members.find((m) => m.user.id === userId)?.role ?? null;
}
@@ -124,7 +124,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
}
async findById(id: string): Promise<IssueReadModel | null> {
const doc = await this.issues.findById(id).lean<IssueDoc>();
const doc = await this.issues.findOne({ id: id }).lean<IssueDoc>();
return doc ? issueFromDoc(doc) : null;
}
@@ -136,7 +136,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
async findByIds(ids: string[]): Promise<IssueReadModel[]> {
if (ids.length === 0) return [];
const docs = await this.issues
.find({ _id: { $in: ids } })
.find({ id: { $in: ids } })
.lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
@@ -322,15 +322,15 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
// Only the top of each tree: a ticket with subtasks under an epic is not one.
const docs = await this.issues
.find({
_id: { $in: [...progress.keys()] },
id: { $in: [...progress.keys()] },
'parent.id': null,
trashedAt: null,
})
.sort({ number: 1 })
.lean<IssueDoc[]>();
return docs.map((doc) => {
const { _id: _ignored, ...row } = progress.get(doc._id) ?? {
_id: doc._id,
const { _id: _ignored, ...row } = progress.get(doc.id) ?? {
_id: doc.id,
total: 0,
finished: 0,
storyPoints: 0,
@@ -456,7 +456,7 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
}
async findById(id: string): Promise<CommentReadModel | null> {
const doc = await this.comments.findById(id).lean<CommentDoc>();
const doc = await this.comments.findOne({ id: id }).lean<CommentDoc>();
return doc ? commentFromDoc(doc) : null;
}
@@ -496,7 +496,7 @@ export class MongoFindReleasesRepository extends FindReleasesRepository {
}
async findById(id: string): Promise<ReleaseReadModel | null> {
const doc = await this.releases.findById(id).lean<ReleaseDoc>();
const doc = await this.releases.findOne({ id: id }).lean<ReleaseDoc>();
return doc ? fromDoc(doc) : null;
}

View File

@@ -21,7 +21,7 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
async repositoriesOf(projectId: string): Promise<GitRepositoryConfig[]> {
const doc = await this.integrations
.findById(projectId)
.findOne({ id: projectId })
.lean<GitIntegrationDoc>();
if (!doc) return [];
if (doc.repositories) return doc.repositories;
@@ -44,11 +44,11 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
repositories: GitRepositoryConfig[],
): Promise<void> {
if (!repositories.length) {
await this.integrations.deleteOne({ _id: projectId });
await this.integrations.deleteOne({ id: projectId });
return;
}
await this.integrations.updateOne(
{ _id: projectId },
{ id: projectId },
{ $set: { repositories }, $unset: { secret: '', createdAt: '' } },
{ upsert: true },
);

View File

@@ -26,7 +26,7 @@ export class MongoNotificationsRepository extends NotificationsRepository {
async insert({ id, ...rest }: NotificationReadModel): Promise<boolean> {
const result = await this.notifications.updateOne(
{ _id: id },
{ id },
{ $setOnInsert: rest },
{ upsert: true },
);
@@ -51,21 +51,21 @@ export class MongoNotificationsRepository extends NotificationsRepository {
async markRead(userId: string, ids: string[] | null): Promise<void> {
await this.notifications.updateMany(
{ userId, readAt: null, ...(ids ? { _id: { $in: ids } } : {}) },
{ userId, readAt: null, ...(ids ? { id: { $in: ids } } : {}) },
{ $set: { readAt: new Date().toISOString() } },
);
}
async settings(userId: string): Promise<NotificationSettingsReadModel> {
const doc = await this.settingsDocs
.findById(userId)
.findOne({ id: userId })
.lean<NotificationSettingsDoc>();
return { userId, email: doc?.email ?? false };
}
async saveSettings(settings: NotificationSettingsReadModel): Promise<void> {
await this.settingsDocs.updateOne(
{ _id: settings.userId },
{ id: settings.userId },
{ $set: { email: settings.email } },
{ upsert: true },
);

View File

@@ -33,12 +33,14 @@ export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository
}
async findById(id: string): Promise<OutgoingWebhook | null> {
const doc = await this.webhooks.findById(id).lean<OutgoingWebhookDoc>();
const doc = await this.webhooks
.findOne({ id: id })
.lean<OutgoingWebhookDoc>();
return doc ? (fromDoc(doc) as OutgoingWebhook) : null;
}
async insert({ id, ...rest }: OutgoingWebhook): Promise<void> {
await this.webhooks.create({ _id: id, ...rest });
await this.webhooks.create({ id, ...rest });
}
async update(
@@ -46,17 +48,14 @@ export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
): Promise<void> {
if (Object.keys(changes).length)
await this.webhooks.updateOne({ _id: id }, { $set: changes });
await this.webhooks.updateOne({ id }, { $set: changes });
}
async delete(id: string): Promise<void> {
await this.webhooks.deleteOne({ _id: id });
await this.webhooks.deleteOne({ id });
}
async recordDelivery(id: string, delivery: WebhookDelivery): Promise<void> {
await this.webhooks.updateOne(
{ _id: id },
{ $set: { lastDelivery: delivery } },
);
await this.webhooks.updateOne({ id }, { $set: { lastDelivery: delivery } });
}
}

View File

@@ -18,13 +18,13 @@ export class RankBackfill implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> {
const missing = await this.issues
.find({ rank: { $exists: false } }, { number: 1 })
.lean<Pick<IssueDoc, '_id' | 'number'>[]>();
.find({ rank: { $exists: false } }, { id: 1, number: 1 })
.lean<Pick<IssueDoc, 'id' | 'number'>[]>();
if (missing.length === 0) return;
const result = await this.issues.bulkWrite(
missing.map((doc) => ({
updateOne: {
filter: { _id: doc._id, rank: { $exists: false } },
filter: { id: doc.id, rank: { $exists: false } },
update: { $set: { rank: implicitRank(doc.number) } },
},
})),

View File

@@ -15,11 +15,17 @@ import {
SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
import { Schema } from 'mongoose';
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Types } from 'mongoose';
/* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */
type Doc<T extends { id: string }> = Omit<T, 'id'> & { _id: string };
/**
* Each document keeps the domain id (a uuid, the same one the event store
* uses) in `id`, unique; `_id` is Mongo's own ObjectId and means nothing
* outside the database.
*/
type Doc<T extends { id: string }> = T & { _id?: Types.ObjectId };
export type ProjectDoc = Doc<ProjectReadModel> & { memberIds: string[] };
/** Links are split: outward ones come from the issue itself, inward ones from other issues' events. */
@@ -34,11 +40,11 @@ export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string };
export type AutomationDoc = Omit<Automation, 'id'> & { _id: string };
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
export type AutomationDoc = Doc<Automation>;
export interface AutomationRunDoc {
/** `ruleId:eventKey`: a rule runs once per event. */
_id: string;
id: string;
at: Date;
}
export interface GitRepositoryDoc {
@@ -52,13 +58,15 @@ export interface GitRepositoryDoc {
* kept: it reads as a repository named "principal" until the list is saved.
*/
export interface GitIntegrationDoc {
_id: string;
/** The project's id. */
id: string;
repositories?: GitRepositoryDoc[];
secret?: string;
createdAt?: string;
}
export interface NotificationSettingsDoc {
_id: string;
/** The user's id. */
id: string;
email: boolean;
}
@@ -79,63 +87,105 @@ export const ReadModels = {
AutomationRun: 'AutomationRun',
} as const;
const userRef = { _id: false, id: String, name: String };
// `type` must be wrapped: a bare `type` key would declare the whole subdocument a String.
const link = {
_id: false,
type: { type: String },
direction: String,
issueId: String,
issueKey: String,
};
const loose = { strict: false, versionKey: false, _id: false } as const;
/*
* Every collection keeps the whole read model (`strict: false`); the props
* declared here are the ones with an index or a shape worth stating. `id` is
* the domain id (unique, what every query goes by); `_id` is Mongo's own.
*/
const LOOSE = { strict: false, versionKey: false } as const;
export const ProjectSchema = new Schema<ProjectDoc>(
{
_id: String,
key: { type: String, unique: true },
isPublic: { type: Boolean, index: true },
memberIds: { type: [String], index: true },
owner: userRef,
members: [{ _id: false, user: userRef, role: String }],
version: Number,
},
{ ...loose, collection: 'projects_view' },
);
@Schema({ _id: false })
class UserRefProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) name!: string;
}
const UserRefSchema = SchemaFactory.createForClass(UserRefProp);
export const IssueSchema = new Schema<IssueDoc>(
{
_id: String,
projectId: { type: String, index: true },
key: { type: String, unique: true },
status: String,
statusCategory: String,
reporter: userRef,
assignee: userRef,
release: { _id: false, id: String, name: String },
sprint: { _id: false, id: String, name: String },
outwardLinks: [link],
inwardLinks: [link],
watchers: [userRef],
parent: { _id: false, id: String, key: String, title: String },
checklist: [{ _id: false, id: String, text: String, done: Boolean }],
attachments: [
{
_id: false,
id: String,
fileName: String,
mimeType: String,
size: Number,
uploadedBy: userRef,
uploadedAt: String,
commentId: String,
},
],
labels: [String],
version: Number,
},
{ ...loose, collection: 'issues_view' },
);
@Schema({ _id: false })
class MemberProp {
@Prop({ type: UserRefSchema }) user!: UserRefProp;
@Prop({ type: String }) role!: string;
}
@Schema({ _id: false })
class LinkProp {
@Prop({ type: String }) type!: string;
@Prop({ type: String }) direction!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) issueKey!: string;
}
const LinkSchema = SchemaFactory.createForClass(LinkProp);
@Schema({ _id: false })
class NamedRefProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) name!: string;
}
@Schema({ _id: false })
class ParentProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) key!: string;
@Prop({ type: String }) title!: string;
}
@Schema({ _id: false })
class ChecklistItemProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) text!: string;
@Prop({ type: Boolean }) done!: boolean;
}
@Schema({ _id: false })
class AttachmentProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) fileName!: string;
@Prop({ type: String }) mimeType!: string;
@Prop({ type: Number }) size!: number;
@Prop({ type: UserRefSchema }) uploadedBy!: UserRefProp;
@Prop({ type: String }) uploadedAt!: string;
@Prop({ type: String }) commentId!: string;
}
@Schema({ ...LOOSE, collection: 'projects_view' })
class ProjectDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, unique: true }) key!: string;
@Prop({ type: Boolean, index: true }) isPublic!: boolean;
@Prop({ type: [String], index: true }) memberIds!: string[];
@Prop({ type: UserRefSchema }) owner!: UserRefProp;
@Prop({ type: [SchemaFactory.createForClass(MemberProp)] })
members!: MemberProp[];
@Prop({ type: Number }) version!: number;
}
export const ProjectSchema = SchemaFactory.createForClass(ProjectDocument);
@Schema({ ...LOOSE, collection: 'issues_view' })
class IssueDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: String, unique: true }) key!: string;
@Prop({ type: String }) status!: string;
@Prop({ type: String }) statusCategory!: string;
@Prop({ type: UserRefSchema }) reporter!: UserRefProp;
@Prop({ type: UserRefSchema }) assignee!: UserRefProp | null;
@Prop({ type: SchemaFactory.createForClass(NamedRefProp) })
release!: NamedRefProp | null;
@Prop({ type: SchemaFactory.createForClass(NamedRefProp) })
sprint!: NamedRefProp | null;
@Prop({ type: [LinkSchema] }) outwardLinks!: LinkProp[];
@Prop({ type: [LinkSchema] }) inwardLinks!: LinkProp[];
@Prop({ type: [UserRefSchema] }) watchers!: UserRefProp[];
@Prop({ type: SchemaFactory.createForClass(ParentProp) })
parent!: ParentProp | null;
@Prop({ type: [SchemaFactory.createForClass(ChecklistItemProp)] })
checklist!: ChecklistItemProp[];
@Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] })
attachments!: AttachmentProp[];
@Prop({ type: [String] }) labels!: string[];
@Prop({ type: Number }) version!: number;
}
export const IssueSchema = SchemaFactory.createForClass(IssueDocument);
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
IssueSchema.index({ projectId: 1, statusCategory: 1 });
IssueSchema.index({ 'assignee.id': 1 });
@@ -147,115 +197,137 @@ IssueSchema.index({ 'sprint.id': 1 });
IssueSchema.index({ trashedAt: 1 }, { sparse: true });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
export const CommentSchema = new Schema<CommentDoc>(
{
_id: String,
issueId: { type: String, index: true },
replyTo: { type: String, index: true },
author: userRef,
version: Number,
},
{ ...loose, collection: 'comments_view' },
);
@Schema({ ...LOOSE, collection: 'comments_view' })
class CommentDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) issueId!: string;
@Prop({ type: String, index: true }) replyTo!: string | null;
@Prop({ type: UserRefSchema }) author!: UserRefProp;
@Prop({ type: Number }) version!: number;
}
export const CommentSchema = SchemaFactory.createForClass(CommentDocument);
CommentSchema.index({ issueId: 1, createdAt: 1 });
export const ReleaseSchema = new Schema<ReleaseDoc>(
{ _id: String, projectId: { type: String, index: true }, version: Number },
{ ...loose, collection: 'releases_view' },
);
@Schema({ ...LOOSE, collection: 'releases_view' })
class ReleaseDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: Number }) version!: number;
}
export const ReleaseSchema = SchemaFactory.createForClass(ReleaseDocument);
export const SprintSchema = new Schema<SprintDoc>(
{
_id: String,
projectId: { type: String, index: true },
status: String,
version: Number,
},
{ ...loose, collection: 'sprints_view' },
);
@Schema({ ...LOOSE, collection: 'sprints_view' })
class SprintDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: String }) status!: string;
@Prop({ type: Number }) version!: number;
}
export const SprintSchema = SchemaFactory.createForClass(SprintDocument);
export const ActivitySchema = new Schema<ActivityDoc>(
{
_id: String,
projectId: String,
issueId: String,
actor: userRef,
},
{ ...loose, collection: 'activity' },
);
@Schema({ ...LOOSE, collection: 'activity' })
class ActivityDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) projectId!: string;
@Prop({ type: String }) issueId!: string | null;
@Prop({ type: UserRefSchema }) actor!: UserRefProp;
}
export const ActivitySchema = SchemaFactory.createForClass(ActivityDocument);
ActivitySchema.index({ projectId: 1, occurredAt: -1 });
ActivitySchema.index({ issueId: 1, occurredAt: -1 });
/** Every status and release step of every ticket (statistics). */
export const TimelineSchema = new Schema<TimelineDoc>(
{ _id: String, projectId: String, issueId: String, at: String },
{ ...loose, collection: 'issue_timeline' },
);
@Schema({ ...LOOSE, collection: 'issue_timeline' })
class TimelineDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) projectId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) at!: string;
}
export const TimelineSchema = SchemaFactory.createForClass(TimelineDocument);
TimelineSchema.index({ projectId: 1, at: 1 });
export const NotificationSchema = new Schema<NotificationDoc>(
{
_id: String,
userId: String,
readAt: String,
createdAt: String,
actor: userRef,
},
{ ...loose, collection: 'notifications' },
);
@Schema({ ...LOOSE, collection: 'notifications' })
class NotificationDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) userId!: string;
@Prop({ type: String }) readAt!: string | null;
@Prop({ type: String }) createdAt!: string;
@Prop({ type: UserRefSchema }) actor!: UserRefProp;
}
export const NotificationSchema =
SchemaFactory.createForClass(NotificationDocument);
NotificationSchema.index({ userId: 1, createdAt: -1 });
NotificationSchema.index({ userId: 1, readAt: 1 });
export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
{ _id: String, email: Boolean },
{ ...loose, collection: 'notification_settings' },
@Schema({ ...LOOSE, collection: 'notification_settings' })
class NotificationSettingsDocument {
/** The user's id. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: Boolean }) email!: boolean;
}
export const NotificationSettingsSchema = SchemaFactory.createForClass(
NotificationSettingsDocument,
);
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
{
_id: String,
projectId: String,
owner: userRef,
name: String,
shared: Boolean,
createdAt: String,
},
{ ...loose, collection: 'saved_filters' },
);
@Schema({ ...LOOSE, collection: 'saved_filters' })
class SavedFilterDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) projectId!: string;
@Prop({ type: UserRefSchema }) owner!: UserRefProp;
@Prop({ type: String }) name!: string;
@Prop({ type: Boolean }) shared!: boolean;
@Prop({ type: String }) createdAt!: string;
}
export const SavedFilterSchema =
SchemaFactory.createForClass(SavedFilterDocument);
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
SavedFilterSchema.index({ projectId: 1, shared: 1 });
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
{ _id: String, repositories: [Object], secret: String, createdAt: String },
{ ...loose, collection: 'git_integrations' },
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: [Object] }) repositories!: GitRepositoryDoc[];
@Prop({ type: String }) secret!: string;
@Prop({ type: String }) createdAt!: string;
}
export const GitIntegrationSchema = SchemaFactory.createForClass(
GitIntegrationDocument,
);
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
{
_id: String,
projectId: { type: String, index: true },
active: Boolean,
events: [String],
createdBy: userRef,
createdAt: String,
},
{ ...loose, collection: 'outgoing_webhooks' },
@Schema({ ...LOOSE, collection: 'outgoing_webhooks' })
class OutgoingWebhookDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: Boolean }) active!: boolean;
@Prop({ type: [String] }) events!: string[];
@Prop({ type: UserRefSchema }) createdBy!: UserRefProp;
@Prop({ type: String }) createdAt!: string;
}
export const OutgoingWebhookSchema = SchemaFactory.createForClass(
OutgoingWebhookDocument,
);
export const AutomationSchema = new Schema<AutomationDoc>(
{
_id: String,
projectId: { type: String, index: true },
active: Boolean,
createdBy: userRef,
createdAt: String,
},
{ ...loose, collection: 'automations' },
);
@Schema({ ...LOOSE, collection: 'automations' })
class AutomationDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: Boolean }) active!: boolean;
@Prop({ type: UserRefSchema }) createdBy!: UserRefProp;
@Prop({ type: String }) createdAt!: string;
}
export const AutomationSchema =
SchemaFactory.createForClass(AutomationDocument);
export const AutomationRunSchema = new Schema<AutomationRunDoc>(
{ _id: String, at: Date },
{ ...loose, collection: 'automation_runs' },
@Schema({ ...LOOSE, collection: 'automation_runs' })
class AutomationRunDocument {
/** `ruleId:eventKey`: a rule runs once per event. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: Date }) at!: Date;
}
export const AutomationRunSchema = SchemaFactory.createForClass(
AutomationRunDocument,
);
// Redeliveries happen within minutes; a month of memory is plenty.
AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 });
@@ -277,17 +349,17 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
];
export function fromDoc<T extends { _id: string }>(
doc: T,
): Omit<T, '_id'> & { id: string } {
const { _id, ...rest } = doc;
return { id: _id, ...rest };
/** A document as the model it holds: Mongo's own `_id` left out. */
export function fromDoc<T extends { id: string }>(
doc: T & { _id?: unknown },
): T {
const { _id: _ignored, ...rest } = doc;
return rest as T;
}
export function issueFromDoc(doc: IssueDoc): IssueReadModel {
const { _id, outwardLinks, inwardLinks, ...rest } = doc;
const { _id: _ignored, outwardLinks, inwardLinks, ...rest } = doc;
return {
id: _id,
...rest,
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
// Tickets projected before attachments existed have none.

View File

@@ -26,7 +26,7 @@ export class MongoSavedFiltersRepository extends SavedFiltersRepository {
}
async findById(id: string): Promise<SavedFilterReadModel | null> {
const doc = await this.filters.findById(id).lean<SavedFilterDoc>();
const doc = await this.filters.findOne({ id: id }).lean<SavedFilterDoc>();
return doc ? toFilter(doc) : null;
}
@@ -35,11 +35,11 @@ export class MongoSavedFiltersRepository extends SavedFiltersRepository {
}
async insert({ id, ...rest }: SavedFilterReadModel): Promise<void> {
await this.filters.create({ _id: id, ...rest });
await this.filters.create({ id, ...rest });
}
async delete(id: string): Promise<void> {
await this.filters.deleteOne({ _id: id });
await this.filters.deleteOne({ id });
}
}

View File

@@ -24,7 +24,7 @@ export class MongoFindSprintsRepository extends FindSprintsRepository {
}
async findById(id: string): Promise<SprintReadModel | null> {
const doc = await this.sprints.findById(id).lean<SprintDoc>();
const doc = await this.sprints.findOne({ id: id }).lean<SprintDoc>();
return doc ? fromDoc(doc) : null;
}
@@ -67,6 +67,6 @@ export class MongoUpsertMaterializedSprintRepository extends UpsertMaterializedS
}
async remove(sprintId: string) {
await this.sprints.deleteOne({ _id: sprintId });
await this.sprints.deleteOne({ id: sprintId });
}
}

View File

@@ -30,11 +30,11 @@ export class StatusCategoryBackfill implements OnApplicationBootstrap {
const projectIds = [...new Set(groups.map((g) => g._id.projectId))];
const projects = await this.projects
.find({ _id: { $in: projectIds } }, { settings: 1 })
.lean<Pick<ProjectDoc, '_id' | 'settings'>[]>();
.find({ id: { $in: projectIds } }, { id: 1, settings: 1 })
.lean<Pick<ProjectDoc, 'id' | 'settings'>[]>();
const workflows = new Map(
projects.map((p) => [
p._id,
p.id,
p.settings
? new ProjectWorkflow(p.settings)
: ProjectWorkflow.default(),

View File

@@ -4,7 +4,7 @@ import { Model } from 'mongoose';
* Idempotent upsert: only applies a change carrying a newer aggregate
* version (event position). Redelivered events become no-ops.
*/
export async function upsertIfNewer<T extends { _id: string }>(
export async function upsertIfNewer<T extends { id: string }>(
model: Model<T>,
id: string,
version: number,
@@ -13,13 +13,13 @@ export async function upsertIfNewer<T extends { _id: string }>(
): Promise<boolean> {
try {
const result = await model.updateOne(
{ _id: id, version: { $lt: version } } as never,
{ id, version: { $lt: version } } as never,
{ $set: { ...set, version }, $setOnInsert: setOnInsert } as never,
{ upsert: true },
);
return result.modifiedCount + result.upsertedCount > 0;
} catch (error) {
// Duplicate _id on upsert: the stored document is already newer.
// Duplicate id on upsert: the stored document is already newer.
if ((error as { code?: number }).code === 11000) return false;
throw error;
}

View File

@@ -64,12 +64,12 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
async remove(projectId: string) {
await Promise.all([
this.projects.deleteOne({ _id: projectId }),
this.projects.deleteOne({ id: projectId }),
this.activity.deleteMany({ projectId }),
this.timeline.deleteMany({ projectId }),
// Views of the board of a project that is gone.
this.savedFilters.deleteMany({ projectId }),
this.gitIntegrations.deleteOne({ _id: projectId }),
this.gitIntegrations.deleteOne({ id: projectId }),
this.outgoingWebhooks.deleteMany({ projectId }),
this.automations.deleteMany({ projectId }),
]);
@@ -102,7 +102,7 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
async remove(issueId: string) {
await Promise.all([
this.issues.deleteOne({ _id: issueId }),
this.issues.deleteOne({ id: issueId }),
this.issues.updateMany(
{
$or: [
@@ -138,21 +138,21 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
async addWatcher(issueId: string, user: UserRefReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'watchers.id': { $ne: user.id } },
{ id: issueId, 'watchers.id': { $ne: user.id } },
{ $push: { watchers: user } },
);
}
async removeWatcher(issueId: string, userId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ id: issueId },
{ $pull: { watchers: { id: userId } } },
);
}
async addChecklistItem(issueId: string, item: ChecklistItemReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'checklist.id': { $ne: item.id } },
{ id: issueId, 'checklist.id': { $ne: item.id } },
{ $push: { checklist: item } },
);
}
@@ -170,14 +170,14 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
);
if (Object.keys(set).length === 0) return;
await this.issues.updateOne(
{ _id: issueId, 'checklist.id': itemId },
{ id: issueId, 'checklist.id': itemId },
{ $set: set },
);
}
async removeChecklistItem(issueId: string, itemId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ id: issueId },
{ $pull: { checklist: { id: itemId } } },
);
}
@@ -185,11 +185,11 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
async putDevLink(issueId: string, link: DevLink) {
// Same URL: the newer report replaces the older one.
await this.issues.updateOne(
{ _id: issueId },
{ id: issueId },
{ $pull: { devLinks: { url: link.url } } },
);
await this.issues.updateOne(
{ _id: issueId },
{ id: issueId },
{ $push: { devLinks: { $each: [link], $slice: -MAX_DEV_LINKS } } },
);
}
@@ -203,14 +203,14 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
{ id: issueId, 'attachments.id': { $ne: attachment.id } },
{ $push: { attachments: attachment } },
);
}
async removeAttachment(issueId: string, attachmentId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ id: issueId },
{ $pull: { attachments: { id: attachmentId } } },
);
}
@@ -219,7 +219,7 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
await this.issues.updateOne(
{
_id: issueId,
id: issueId,
[field]: {
$not: { $elemMatch: { type: link.type, issueId: link.issueId } },
},
@@ -234,13 +234,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
) {
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
await this.issues.updateOne(
{ _id: issueId },
{ id: issueId },
{ $pull: { [field]: { type: link.type, issueId: link.issueId } } },
);
}
async setCommentCount(issueId: string, commentCount: number) {
await this.issues.updateOne({ _id: issueId }, { $set: { commentCount } });
await this.issues.updateOne({ id: issueId }, { $set: { commentCount } });
}
async recategorize(
@@ -289,7 +289,7 @@ export class MongoUpsertMaterializedCommentRepository extends UpsertMaterialized
}
async remove(id: string) {
await this.comments.deleteOne({ _id: id });
await this.comments.deleteOne({ id });
}
}
@@ -311,7 +311,7 @@ export class MongoUpsertMaterializedReleaseRepository extends UpsertMaterialized
}
async remove(releaseId: string) {
await this.releases.deleteOne({ _id: releaseId });
await this.releases.deleteOne({ id: releaseId });
}
}
@@ -326,7 +326,7 @@ export class MongoUpsertMaterializedActivityRepository extends UpsertMaterialize
async insert({ id, ...fields }: ActivityReadModel): Promise<boolean> {
const result = await this.activity.updateOne(
{ _id: id },
{ id },
{ $setOnInsert: fields },
{ upsert: true },
);
@@ -345,7 +345,7 @@ export class MongoUpsertMaterializedTimelineRepository extends UpsertMaterialize
async record({ id, ...fields }: IssueTimelineEntry): Promise<void> {
await this.timeline.updateOne(
{ _id: id },
{ id },
{ $setOnInsert: fields },
{ upsert: true },
);

View File

@@ -1,13 +1,18 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { Column, Entity, Index, PrimaryGeneratedColumn, Unique } from 'typeorm';
/*
* Write-side lookup tables. The event store holds the truth; these only
* enforce uniqueness and sequences that cannot be checked per aggregate.
* Each row has a uuid `id`; what makes it unique is a constraint apart.
*/
@Entity('project_keys')
export class ProjectKeyEntity {
@PrimaryColumn({ length: 10 })
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index('uq_project_keys_key', { unique: true })
@Column({ length: 10 })
key!: string;
@Column({ name: 'project_id', length: 64 })
@@ -16,7 +21,12 @@ export class ProjectKeyEntity {
@Entity('issue_sequences')
export class IssueSequenceEntity {
@PrimaryColumn({ name: 'project_id', length: 64 })
@PrimaryGeneratedColumn('uuid')
id!: string;
/** `ON CONFLICT (project_id)` counts on it. */
@Index('uq_issue_sequences_project', { unique: true })
@Column({ name: 'project_id', length: 64 })
projectId!: string;
@Column({ name: 'last_number', type: 'int', default: 0 })
@@ -24,11 +34,15 @@ export class IssueSequenceEntity {
}
@Entity('release_names')
@Unique('uq_release_names_project_name', ['projectId', 'name'])
export class ReleaseNameEntity {
@PrimaryColumn({ name: 'project_id', length: 64 })
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'project_id', length: 64 })
projectId!: string;
@PrimaryColumn({ length: 60 })
@Column({ length: 60 })
name!: string;
@Column({ name: 'release_id', length: 64 })

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { DynamicModule, Module, Type } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { MongooseModule } from '@nestjs/mongoose';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -54,8 +54,6 @@ import {
} from './mongo/upsert-materialized.repositories';
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
import { RankBackfill } from './mongo/rank.backfill';
import { ReminderScheduler } from './notifications/reminder.scheduler';
import { TrashScheduler } from './notifications/trash.scheduler';
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
import {
PostgresIssueNumbersRepository,
@@ -158,13 +156,16 @@ const PORTS = [
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
],
providers: [
...PORTS,
StatusCategoryBackfill,
RankBackfill,
ReminderScheduler,
TrashScheduler,
],
providers: [...PORTS, StatusCategoryBackfill, RankBackfill],
exports: PORTS.map((port) => port.provide),
})
export class TrackerInfrastructureModule {}
export class TrackerInfrastructureModule {
/**
* The user directory asks the identity context through its service, so
* the tracker needs that context's application module: the composition
* root passes the same one the identity context is built with.
*/
static withIdentity(identity: Type | DynamicModule): DynamicModule {
return { module: TrackerInfrastructureModule, imports: [identity] };
}
}

View File

@@ -5,8 +5,7 @@ import {
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommandBus } from '@nestjs/cqrs';
import { SendDueRemindersCommand } from '@task-stream/api/tracker/application';
import { NotificationsService } from '@task-stream/api/tracker/application';
const HOUR = 3_600_000;
@@ -34,7 +33,7 @@ export class ReminderScheduler
private timers: ReturnType<typeof setTimeout>[] = [];
constructor(
private readonly commandBus: CommandBus,
private readonly notifications: NotificationsService,
private readonly config: ConfigService,
) {}
@@ -56,8 +55,8 @@ export class ReminderScheduler
const timeZone =
this.config.get<string>('REMINDERS_TIMEZONE') || 'Europe/Madrid';
try {
const sent: number = await this.commandBus.execute(
new SendDueRemindersCommand(dayIn(new Date(), timeZone)),
const sent = await this.notifications.sendDueReminders(
dayIn(new Date(), timeZone),
);
if (sent) this.logger.log(`Due-date reminders checked (${sent})`);
} catch (error) {

View File

@@ -5,9 +5,8 @@ import {
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommandBus } from '@nestjs/cqrs';
import {
EmptyTrashCommand,
IssuesService,
TRASH_DAYS,
} from '@task-stream/api/tracker/application';
@@ -25,7 +24,7 @@ export class TrashScheduler
private timers: ReturnType<typeof setTimeout>[] = [];
constructor(
private readonly commandBus: CommandBus,
private readonly issues: IssuesService,
private readonly config: ConfigService,
) {}
@@ -46,9 +45,7 @@ export class TrashScheduler
Number(this.config.get('TRASH_DAYS') ?? TRASH_DAYS) || TRASH_DAYS;
const before = new Date(Date.now() - days * 24 * HOUR).toISOString();
try {
const deleted: number = await this.commandBus.execute(
new EmptyTrashCommand(before),
);
const deleted = await this.issues.emptyTrash(before);
if (deleted)
this.logger.log(`Deleted ${deleted} ticket(s) from the trash`);
} catch (error) {

View File

@@ -13,6 +13,8 @@ import { CommentsController } from './http/comments.controller';
import { IssuesController } from './http/issues.controller';
import { ExploreController } from './http/explore.controller';
import { ProposalLimiter } from './http/proposal-limiter';
import { ReminderScheduler } from './schedulers/reminder.scheduler';
import { TrashScheduler } from './schedulers/trash.scheduler';
import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller';
@@ -65,6 +67,9 @@ import { AutomationResolver } from './graphql/automation.resolver';
providers: [
Refs,
ProposalLimiter,
// Timers: entry points like the HTTP and GraphQL ones.
ReminderScheduler,
TrashScheduler,
ProjectResolver,
IssueResolver,
CommentResolver,

View File

@@ -6,6 +6,7 @@ import {
IssueLink,
IssuesGateway,
IssueLinkType,
IssueSummary,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
@@ -120,6 +121,20 @@ export class IssueFacade {
}
/** Places the open ticket under another one by its key; null: back to the top. */
/**
* Tickets to link to or hang the open one from: the most recently updated
* of all my projects (links may cross projects), the open one aside.
*/
candidates(): Observable<IssueSummary[]> {
return this.issues
.mine({ assignedToMe: false, limit: 200 })
.pipe(
map((page) =>
page.items.filter((item) => item.id !== this.store.issue()?.id),
),
);
}
setParent(parentKey: string | null): void {
this.withIssue((issueId) =>
this.dispatch.parentRequested({ issueId, parentKey }),

View File

@@ -10,6 +10,7 @@ import {
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiCheckboxComponent,
PiEmptyStateComponent,
PiIconButtonComponent,
PiIconComponent,
@@ -34,6 +35,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
selector: 'ts-backlog-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCheckboxComponent,
BulkBarComponent,
RouterLink,
CdkDrag,
@@ -96,14 +98,15 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
[cdkDragData]="issue"
[class.is-selected]="selected().has(issue.id)"
>
<input
type="checkbox"
<pi-checkbox
class="ts-backlog__pick"
[attr.aria-label]="'Seleccionar ' + issue.key"
[disabled]="!project.canContribute()"
[checked]="selected().has(issue.id)"
(change)="toggle(issue.id, $any($event.target).checked)"
/>
(checkedChange)="toggle(issue.id, $event)"
><span class="visually-hidden"
>Seleccionar {{ issue.key }}</span
></pi-checkbox
>
<span class="ts-backlog__pos ts-mono">{{ i + 1 }}</span>
<span
class="ts-backlog__type"

View File

@@ -11,7 +11,9 @@ import {
TemplateRef,
viewChild,
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { form, FormField, required } from '@angular/forms/signals';
import { catchError, distinctUntilChanged, filter, of, switchMap } from 'rxjs';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
@@ -23,6 +25,7 @@ import {
PiLabelDirective,
PiNoteComponent,
PiProgressBarComponent,
PiSearchableSelectFieldComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextareaComponent,
@@ -93,6 +96,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
PiLabelDirective,
PiNoteComponent,
PiProgressBarComponent,
PiSearchableSelectFieldComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
@@ -404,8 +408,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(submit)="$event.preventDefault(); setParent()"
novalidate
>
<pi-text-field
placeholder="Colgar de… (clave de la épica, p. ej. WEB-3)"
<pi-searchable-select-field
placeholder="Colgar de… (la épica o el ticket padre)"
searchPlaceholder="Busca por clave o título"
emptyLabel="Ningún ticket de este proyecto coincide"
[options]="parentOptions()"
[(value)]="parentKey"
/>
<pi-action-button
@@ -413,7 +420,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
color="grey"
appearance="outline"
text="Colgar"
[disabled]="!parentKey().trim()"
[disabled]="!parentKey()"
(execute)="setParent()"
/>
</form>
@@ -512,15 +519,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
[options]="linkTypeOptions"
[(value)]="linkType"
/>
<pi-text-field
placeholder="Clave, p. ej. BE-3"
<pi-searchable-select-field
placeholder="Elige el ticket"
searchPlaceholder="Busca por clave o título"
emptyLabel="Ningún ticket coincide"
[options]="linkOptions()"
[(value)]="linkTarget"
/>
<pi-action-button
size="small"
color="primary"
text="Relacionar"
[disabled]="!linkTarget().trim()"
[disabled]="!linkTarget()"
(execute)="link()"
/>
</form>
@@ -1066,7 +1076,33 @@ export class IssuePage {
// Inline forms: plain signals (a required validator would flag them red right after submitting).
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
protected readonly linkTarget = signal('');
protected readonly linkTarget = signal<string | null>(null);
/** Tickets of my projects, to pick from instead of typing a key. */
private readonly candidates = toSignal(
toObservable(computed(() => this.facade.issue()?.id ?? null)).pipe(
filter((id): id is string => !!id),
distinctUntilChanged(),
switchMap(() => this.facade.candidates().pipe(catchError(() => of([])))),
),
{ initialValue: [] },
);
/** Any of them: links may cross projects (FE ↔ BE ↔ QA). */
protected readonly linkOptions = computed(() =>
this.candidates().map((issue) => ({
value: issue.key,
label: `${issue.key} · ${issue.title}`,
})),
);
/** Only this project's: a ticket hangs from one of its own. */
protected readonly parentOptions = computed(() => {
const projectId = this.facade.issue()?.projectId;
return this.candidates()
.filter((issue) => issue.projectId === projectId)
.map((issue) => ({
value: issue.key,
label: `${issue.key} · ${issue.title}`,
}));
});
protected readonly commentBody = signal('');
/** The comment being answered, and the answer being written. */
protected readonly replyingTo = signal<string | null>(null);
@@ -1133,7 +1169,7 @@ export class IssuePage {
protected readonly checklistText = signal('');
protected readonly subtaskTitle = signal('');
protected readonly subtaskType = signal<string | null>(null);
protected readonly parentKey = signal('');
protected readonly parentKey = signal<string | null>(null);
/** Active types; subtasks start as tasks when the project has them. */
protected readonly subtaskTypeOptions = computed<
PiSelectFieldOption<string>[]
@@ -1227,10 +1263,10 @@ export class IssuePage {
}
protected setParent(): void {
const key = this.parentKey().trim();
const key = this.parentKey();
if (!key) return;
this.facade.setParent(key);
this.parentKey.set('');
this.parentKey.set(null);
}
protected move(to: string): void {
@@ -1283,13 +1319,13 @@ export class IssuePage {
}
protected link(): void {
const targetKey = this.linkTarget().trim();
const targetKey = this.linkTarget();
if (!targetKey) return;
this.facade.link(
(this.linkType() ?? IssueLinkType.RELATES_TO) as IssueLinkType,
targetKey,
);
this.linkTarget.set('');
this.linkTarget.set(null);
}
protected unlink(link: IssueLink): void {

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiCheckboxComponent,
PiEmptyStateComponent,
PiIconComponent,
PiNoteComponent,
@@ -78,6 +79,7 @@ const numberOf = (key: string) => Number(key.split('-').pop());
selector: 'ts-list-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCheckboxComponent,
BlockedComponent,
BulkBarComponent,
VoteComponent,
@@ -165,12 +167,13 @@ const numberOf = (key: string) => Number(key.split('-').pop());
<tr [class.is-selected]="selected().has(issue.id)">
@if (project.canContribute()) {
<td class="ts-list__pick">
<input
type="checkbox"
[attr.aria-label]="'Seleccionar ' + issue.key"
<pi-checkbox
[checked]="selected().has(issue.id)"
(change)="toggle(issue.id, $any($event.target).checked)"
/>
(checkedChange)="toggle(issue.id, $event)"
><span class="visually-hidden"
>Seleccionar {{ issue.key }}</span
></pi-checkbox
>
</td>
}
<td class="ts-list__key">

View File

@@ -17,6 +17,7 @@ import {
} from '@angular/forms/signals';
import {
PiActionButtonComponent,
PiCheckboxComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiSkeletonComponent,
@@ -34,6 +35,7 @@ import { MyWorkComponent } from './my-work';
selector: 'ts-projects-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCheckboxComponent,
FormField,
GoToComponent,
ProjectCardComponent,
@@ -123,14 +125,13 @@ import { MyWorkComponent } from './my-work';
>
<pi-label>Descripción</pi-label>
</pi-textarea>
<label class="ts-public-check">
<input type="checkbox" [formField]="f.isPublic" />
<span>
<strong>Proyecto público</strong>
<div class="ts-public-check">
<pi-checkbox label="Proyecto público" [formField]="f.isPublic" />
<p>
Cualquiera, también sin iniciar sesión, podrá verlo (solo lectura).
Se puede cambiar después en Configuración.
</span>
</label>
</p>
</div>
<pi-action-button
size="w100"
color="primary"
@@ -164,22 +165,11 @@ import { MyWorkComponent } from './my-work';
}
}
.ts-public-check {
display: flex;
align-items: flex-start;
gap: 0.625rem;
font-size: 0.8125rem;
color: var(--ts-muted);
cursor: pointer;
input {
width: 1rem;
height: 1rem;
margin-top: 0.125rem;
accent-color: var(--ts-brand);
}
strong {
display: block;
color: var(--ts-ink);
font-size: 0.875rem;
p {
/* Under the checkbox's text, past the box. */
margin: 0 0 0 2.5rem;
color: var(--ts-muted);
font-size: 0.8125rem;
}
}
.ts-projects {

View File

@@ -16,7 +16,7 @@ import {
untracked,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
import { PiCheckboxComponent, PiIconComponent, PiNoteComponent } from '@pi/ui';
import { GitIntegrationComponent } from '../ui/git-integration';
import { WebhooksComponent } from '../ui/webhooks';
import { TransferComponent } from '../ui/transfer';
@@ -57,6 +57,7 @@ function idFrom(name: string): string {
selector: 'ts-settings-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCheckboxComponent,
RouterLink,
GitIntegrationComponent,
WebhooksComponent,
@@ -263,18 +264,16 @@ function idFrom(name: string): string {
})
"
/>
<label class="ts-check">
<input
type="checkbox"
[checked]="status.requiresReason"
(change)="
edit('statuses', i, {
requiresReason: $any($event.target).checked,
})
"
/>
<span class="visually-hidden">Pide motivo</span>
</label>
<pi-checkbox
class="ts-check"
[checked]="status.requiresReason"
(checkedChange)="
edit('statuses', i, { requiresReason: $event })
"
><span class="visually-hidden"
>Pide motivo</span
></pi-checkbox
>
<ng-container
[ngTemplateOutlet]="archive"
[ngTemplateOutletContext]="{
@@ -330,18 +329,15 @@ function idFrom(name: string): string {
@for (to of activeStatuses(); track to.id) {
<td>
@if (from.id !== to.id) {
<input
type="checkbox"
[attr.aria-label]="from.name + ' → ' + to.name"
<pi-checkbox
[checked]="allows(from.id, to.id)"
(change)="
toggleTransition(
from.id,
to.id,
$any($event.target).checked
)
(checkedChange)="
toggleTransition(from.id, to.id, $event)
"
/>
><span class="visually-hidden"
>{{ from.name }} → {{ to.name }}</span
></pi-checkbox
>
}
</td>
}

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import {
PiActionButtonComponent,
PiCheckboxComponent,
PiSelectFieldComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
@@ -29,21 +30,26 @@ const NOBODY = '__none__';
selector: 'ts-bulk-bar',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCheckboxComponent,
PiActionButtonComponent,
PiSelectFieldComponent,
PiTextFieldComponent,
],
template: `
<div class="ts-bulk" role="toolbar" aria-label="Edición en bloque">
<label class="ts-bulk__all">
<input
type="checkbox"
[checked]="allSelected()"
[indeterminate]="count() > 0 && !allSelected()"
(change)="selectAll.emit($any($event.target).checked)"
/>
{{ count() ? count() + ' seleccionados' : 'Seleccionar todos' }}
</label>
<pi-checkbox
class="ts-bulk__all"
[checked]="allSelected()"
[indeterminate]="count() > 0 && !allSelected()"
[label]="
count() === 1
? '1 seleccionado'
: count()
? count() + ' seleccionados'
: 'Seleccionar todos'
"
(checkedChange)="selectAll.emit($event)"
/>
@if (count()) {
<pi-select-field [options]="fields" [(value)]="field" />
@switch (field()) {

View File

@@ -9,7 +9,7 @@ import {
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PiTextFieldComponent } from '@pi/ui';
import { PiCheckboxComponent, PiTextFieldComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import { Webhook, WebhooksGateway } from '@task-stream/web/tracker/domain';
import { timeAgo } from './copy';
@@ -38,7 +38,7 @@ const EVENT_LABEL: Record<string, string> = {
@Component({
selector: 'ts-webhooks',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiTextFieldComponent],
imports: [PiCheckboxComponent, PiTextFieldComponent],
styleUrl: './settings-card.scss',
template: `
<section class="ts-panel ts-block ts-hooks" aria-labelledby="hooks-title">
@@ -113,24 +113,18 @@ const EVENT_LABEL: Record<string, string> = {
<pi-text-field placeholder="https://…" [(value)]="url" />
<fieldset class="ts-hooks__picks">
<legend>Eventos</legend>
<label>
<input
type="checkbox"
[checked]="picked().includes('*')"
(change)="pick('*', $any($event.target).checked)"
/>
Todos
</label>
<pi-checkbox
label="Todos"
[checked]="picked().includes('*')"
(checkedChange)="pick('*', $event)"
/>
@for (event of events(); track event) {
<label>
<input
type="checkbox"
[disabled]="picked().includes('*')"
[checked]="picked().includes(event)"
(change)="pick(event, $any($event.target).checked)"
/>
{{ label(event) }}
</label>
<pi-checkbox
[label]="label(event)"
[disabled]="picked().includes('*')"
[checked]="picked().includes(event)"
(checkedChange)="pick(event, $event)"
/>
}
</fieldset>
<div>
@@ -214,11 +208,6 @@ const EVENT_LABEL: Record<string, string> = {
margin-bottom: 0.25rem;
color: var(--ts-muted);
}
label {
display: flex;
gap: 0.375rem;
align-items: center;
}
}
`,
})