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: * Composition root. Each bounded context is assembled outside-in:
* presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters) * presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters)
*/ */
const identity = IdentityPresentersModule.withApplication( const identityApplication = IdentityApplicationModule.withInfrastructure(
IdentityApplicationModule.withInfrastructure(IdentityInfrastructureModule), 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( const tracker = TrackerPresentersModule.withApplication(
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule), TrackerApplicationModule.withInfrastructure(
TrackerInfrastructureModule.withIdentity(identityApplication),
),
); );
@Module({ @Module({
@@ -47,6 +52,8 @@ const tracker = TrackerPresentersModule.withApplication(
url: config.getOrThrow<string>('POSTGRES_URL'), url: config.getOrThrow<string>('POSTGRES_URL'),
autoLoadEntities: true, autoLoadEntities: true,
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'), 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` | | `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` | | `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` | | `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`). 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`). - `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. Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, 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`. - `infrastructure` nunca importa `presenters`.
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web - 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). no depende de la API (y viceversa).
@@ -103,6 +112,9 @@ sequenceDiagram
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`). 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 - **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`). (`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 ### De escritura a lectura: Redis
@@ -122,6 +134,13 @@ sequenceDiagram
que reentregar un evento no tiene efecto. que reentregar un evento no tiene efecto.
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en - Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
`domain/read-models`. `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 ### 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 - 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`. depende de `lodash`.
- Descripciones y comentarios con editor visual (guardado en Markdown), con menciones y capturas pegadas. - 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> { async upsert(user: User): Promise<void> {
await this.users.updateOne( await this.users.updateOne(
{ _id: user.id }, { id: user.id },
{ $set: { name: user.name, email: user.email, isActive: user.isActive } }, { $set: { name: user.name, email: user.email, isActive: user.isActive } },
{ upsert: true }, { upsert: true },
); );
} }
async findById(id: string): Promise<User | null> { 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; return doc ? this.toUser(doc) : null;
} }
async findByIds(ids: string[]): Promise<User[]> { async findByIds(ids: string[]): Promise<User[]> {
if (ids.length === 0) return []; 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)); return docs.map((d) => this.toUser(d));
} }
@@ -48,7 +48,7 @@ export class MongoUserRegistry extends UserRegistry {
private toUser(doc: UserDocument): User { private toUser(doc: UserDocument): User {
return this.factory.restore({ return this.factory.restore({
id: doc._id, id: doc.id,
name: doc.name, name: doc.name,
email: doc.email, email: doc.email,
isActive: doc.isActive, isActive: doc.isActive,

View File

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

View File

@@ -2,6 +2,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
Generated,
Index, Index,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Unique, Unique,
@@ -13,12 +14,21 @@ import {
*/ */
@Entity('events') @Entity('events')
@Unique('uq_events_stream_position', ['streamId', 'position']) @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 { export class EventEntity {
/** Global order of the store, used to relay events in sequence. */ /** The event's own id. */
@PrimaryGeneratedColumn('increment', { type: 'bigint' }) @PrimaryGeneratedColumn('uuid')
id!: string; 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 }) @Column({ name: 'stream_id', length: 64 })
streamId!: string; streamId!: string;

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
CreateAutomationCommand, CreateAutomationCommand,
DeleteAutomationCommand, DeleteAutomationCommand,
GetAutomationsQuery, GetAutomationsQuery,
RunAutomationCommand,
UpdateAutomationCommand, UpdateAutomationCommand,
} from './automations/automations'; } from './automations/automations';
import { Automation } from './ports/automations.repository'; 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> { delete(actorId: string, automationId: string): Promise<void> {
return this.commandBus.execute( return this.commandBus.execute(
new DeleteAutomationCommand(actorId, automationId), 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 { randomUUID } from 'node:crypto';
import { Logger } from '@nestjs/common';
import { import {
Command, Command,
CommandBus,
CommandHandler, CommandHandler,
EventPublisher, EventPublisher,
EventsHandler,
ICommandHandler, ICommandHandler,
IEventHandler,
IQueryHandler, IQueryHandler,
Query, Query,
QueryHandler, QueryHandler,
@@ -17,17 +13,11 @@ import {
NotFoundError, NotFoundError,
} from '@task-stream/api/shared/domain'; } from '@task-stream/api/shared/domain';
import { import {
CommentAddedEvent,
CommentFactory, CommentFactory,
Issue, Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssuePriority, IssuePriority,
IssueStatusChangedEvent,
Project, Project,
SYSTEM_ACTOR_ID, SYSTEM_ACTOR_ID,
TrackerEvent,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access'; import { WriteAccess } from '../commands/write-access';
import { import {
@@ -42,8 +32,6 @@ import {
AutomationConditions, AutomationConditions,
AutomationTrigger, AutomationTrigger,
normalizeRule, normalizeRule,
ruleApplies,
triggerOf,
} from './automation-rules'; } from './automation-rules';
/** A project keeps at most this many 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 = [ export const AUTOMATION_HANDLERS = [
CreateAutomationCommandHandler, CreateAutomationCommandHandler,
UpdateAutomationCommandHandler, UpdateAutomationCommandHandler,
DeleteAutomationCommandHandler, DeleteAutomationCommandHandler,
RunAutomationCommandHandler, RunAutomationCommandHandler,
GetAutomationsQueryHandler, 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 { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { VoteIssueCommandHandler } from './vote-issue.command-handler'; import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { SubmitProposalCommandHandler } from './submit-proposal.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler';
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.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 { RankIssueCommandHandler } from './rank-issue.command-handler';
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler'; import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers'; import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
import { TRASH_HANDLERS } from './trash'; import { TRASH_HANDLERS } from './trash';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import { import {
@@ -46,10 +43,8 @@ import {
} from './change-checklist.command-handler'; } from './change-checklist.command-handler';
export const COMMAND_HANDLERS = [ export const COMMAND_HANDLERS = [
SubmitProposalCommandHandler,
VoteIssueCommandHandler, VoteIssueCommandHandler,
ReactToCommentCommandHandler, ReactToCommentCommandHandler,
DuplicateIssueCommandHandler,
CreateProjectCommandHandler, CreateProjectCommandHandler,
UpdateProjectCommandHandler, UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler, UpdateProjectSettingsCommandHandler,
@@ -85,7 +80,6 @@ export const COMMAND_HANDLERS = [
RankIssueCommandHandler, RankIssueCommandHandler,
WriteReleaseNotesCommandHandler, WriteReleaseNotesCommandHandler,
...SPRINT_COMMAND_HANDLERS, ...SPRINT_COMMAND_HANDLERS,
BulkUpdateIssuesCommandHandler,
...TRASH_HANDLERS, ...TRASH_HANDLERS,
AddChecklistItemCommandHandler, AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler, UpdateChecklistItemCommandHandler,

View File

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

View File

@@ -1,14 +1,17 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import { import {
ActivityReadModel, ActivityReadModel,
EpicReadModel, EpicReadModel,
IssueDetails, IssueDetails,
IssueLinkType, IssueLinkType,
IssuePriority,
IssueReadModel, IssueReadModel,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
import { AssignIssueCommand } from './commands/assign-issue.command'; import { AssignIssueCommand } from './commands/assign-issue.command';
import { DuplicateIssueCommand } from './commands/duplicate-issue.command';
import { VoteIssueCommand } from './commands/vote-issue.command'; import { VoteIssueCommand } from './commands/vote-issue.command';
import { import {
AttachFileCommand, AttachFileCommand,
@@ -19,13 +22,12 @@ import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command'; import { WatchIssueCommand } from './commands/watch-issue.command';
import { SetIssueParentCommand } from './commands/set-issue-parent.command'; import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import { RankIssueCommand } from './commands/rank-issue.command'; import { RankIssueCommand } from './commands/rank-issue.command';
import { DeleteIssueCommand } from './commands/delete-issue.command';
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash'; 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 { GetTrashQuery } from './queries/get-trash.query';
import {
BulkChange,
BulkResult,
BulkUpdateIssuesCommand,
} from './commands/bulk-update-issues';
import { import {
AddChecklistItemCommand, AddChecklistItemCommand,
RemoveChecklistItemCommand, RemoveChecklistItemCommand,
@@ -62,6 +64,10 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
releaseId?: string | null; releaseId?: string | null;
/** Epic or ticket the new one breaks down. */ /** Epic or ticket the new one breaks down. */
parentId?: string | null; 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 { export interface IssueLinkInput {
@@ -75,6 +81,8 @@ export class IssuesService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {} ) {}
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> { create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
@@ -85,6 +93,8 @@ export class IssuesService {
assigneeId, assigneeId,
releaseId, releaseId,
parentId, parentId,
initialStatus,
useTemplate,
...details ...details
} = input; } = input;
return this.commandBus.execute( return this.commandBus.execute(
@@ -97,6 +107,8 @@ export class IssuesService {
assigneeId, assigneeId,
releaseId, releaseId,
parentId, 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, actorId: string,
issueIds: string[], issueIds: string[],
change: BulkChange, change: BulkChange,
): Promise<BulkResult> { ): Promise<BulkResult> {
return this.commandBus.execute( const ids = [...new Set(issueIds)];
new BulkUpdateIssuesCommand(actorId, issueIds, change), 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`. */ /** 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)); 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> { restore(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId)); return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
} }
@@ -266,9 +334,35 @@ export class IssuesService {
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on)); 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> { * A new ticket like this one: what it says (type, title, description,
return this.commandBus.execute(new DuplicateIssueCommand(actorId, issueId)); * 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( trashed(

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { NotificationReadModel } from '@task-stream/api/tracker/domain'; import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { SendDueRemindersCommand } from './notifications/reminders';
import { import {
MarkNotificationsReadCommand, MarkNotificationsReadCommand,
UpdateNotificationSettingsCommand, UpdateNotificationSettingsCommand,
@@ -36,6 +37,11 @@ export class NotificationsService {
} }
/** `ids` omitted: everything. */ /** `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> { markRead(userId: string, ids?: string[] | null): Promise<void> {
return this.commandBus.execute( return this.commandBus.execute(
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null), new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),

View File

@@ -16,7 +16,7 @@ import {
TrackerEvent, TrackerEvent,
withoutMentionMarkup, withoutMentionMarkup,
} from '@task-stream/api/tracker/domain'; } 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 { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.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 { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { import {
ForbiddenError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
IssuePriority,
ProjectAnalyticsReadModel, ProjectAnalyticsReadModel,
ProjectReadModel, ProjectReadModel,
ProjectRole, ProjectRole,
ProjectSettings, ProjectSettings,
ProjectStatsReadModel, ProjectStatsReadModel,
ReleaseReadModel, ReleaseReadModel,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain'; } 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 { AddProjectMemberCommand } from './commands/add-project-member.command';
import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command'; import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command';
import { CommandResult } from './commands/command-result'; import { CommandResult } from './commands/command-result';
@@ -35,22 +42,68 @@ export class ProjectsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus, 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. */ * A bug report or an idea from anyone, for a public project that takes
propose( * them. It becomes a ticket labelled «propuesta», opened by TaskStream
project: string, * (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, kind: ProposalKind,
title: string, title: string,
description: string, description: string,
author: string | null, author: string | null,
): Promise<CommandResult> { ): 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( 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> { showcase(project: string): Promise<Showcase> {
return this.queryBus.execute(new GetShowcaseQuery(project)); 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 { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.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. */ /** Tickets with this label are offered to newcomers on public pages. */
export const GOOD_FIRST_LABEL = 'good-first-issue'; 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 { TRANSFER_HANDLERS } from './transfer/import-export';
import { AutomationsService } from './automations.service'; import { AutomationsService } from './automations.service';
import { AUTOMATION_HANDLERS } from './automations/automations'; import { AUTOMATION_HANDLERS } from './automations/automations';
import { AutomationsEventHandler } from './automations/automations.event-handler';
/** Notifications: derived from events, kept and dismissed per person. */ /** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [ const NOTIFICATIONS = [
@@ -97,6 +98,7 @@ const SERVICES = [
...WEBHOOK_HANDLERS, ...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS, ...TRANSFER_HANDLERS,
...AUTOMATION_HANDLERS, ...AUTOMATION_HANDLERS,
AutomationsEventHandler,
], ],
exports: SERVICES, exports: SERVICES,
}) })

View File

@@ -1,31 +1,115 @@
import { Injectable } from '@nestjs/common'; 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 { import {
ExportIssuesQuery, ExportIssuesQuery,
ImportIssuesCommand,
ImportReport, ImportReport,
MAX_IMPORT_MB,
} from './transfer/import-export'; } from './transfer/import-export';
import { planImport } from './transfer/transfer';
/** Tickets in and out as CSV (TaskStream's own, or a Jira export). */ /** Tickets in and out as CSV (TaskStream's own, or a Jira export). */
@Injectable() @Injectable()
export class TransferService { export class TransferService {
constructor( constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly issues: IssuesService,
) {} ) {}
exportCsv(viewerId: string | null, projectId: string): Promise<string> { exportCsv(viewerId: string | null, projectId: string): Promise<string> {
return this.queryBus.execute(new ExportIssuesQuery(viewerId, projectId)); 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, actorId: string,
projectId: string, projectId: string,
csv: string, csv: string,
dryRun: boolean, dryRun: boolean,
): Promise<ImportReport> { ): Promise<ImportReport> {
return this.commandBus.execute( (await this.access.project(projectId)).assertCanManage(actorId);
new ImportIssuesCommand(actorId, projectId, csv, dryRun), 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 { import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
Command,
CommandBus,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { import {
DEFAULT_PROJECT_SETTINGS, DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow, ProjectWorkflow,
} from '@task-stream/api/tracker/domain'; } 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 { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from '../queries/read-access'; 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. */ /** A file is at most this many megabytes. */
export const MAX_IMPORT_MB = 5; export const MAX_IMPORT_MB = 5;
@@ -36,18 +23,6 @@ export interface ImportReport {
warnings: { line: number; message: string }[]; 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> { export class ExportIssuesQuery extends Query<string> {
constructor( constructor(
public readonly viewerId: string | null, 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) @QueryHandler(ExportIssuesQuery)
export class ExportIssuesQueryHandler export class ExportIssuesQueryHandler
implements IQueryHandler<ExportIssuesQuery, string> implements IQueryHandler<ExportIssuesQuery, string>
@@ -182,7 +55,4 @@ export class ExportIssuesQueryHandler
} }
} }
export const TRANSFER_HANDLERS = [ export const TRANSFER_HANDLERS = [ExportIssuesQueryHandler];
ImportIssuesCommandHandler,
ExportIssuesQueryHandler,
];

View File

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

View File

@@ -41,12 +41,14 @@ export class MongoAutomationsRepository extends AutomationsRepository {
} }
async findById(id: string): Promise<Automation | null> { 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; return doc ? (fromDoc(doc) as Automation) : null;
} }
async insert({ id, ...rest }: Automation): Promise<void> { async insert({ id, ...rest }: Automation): Promise<void> {
await this.automations.create({ _id: id, ...rest }); await this.automations.create({ id, ...rest });
} }
async update( async update(
@@ -54,16 +56,16 @@ export class MongoAutomationsRepository extends AutomationsRepository {
changes: Partial<Pick<Automation, 'name' | 'active'>>, changes: Partial<Pick<Automation, 'name' | 'active'>>,
): Promise<void> { ): Promise<void> {
if (Object.keys(changes).length) 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> { 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> { async claimRun(ruleId: string, eventKey: string): Promise<boolean> {
try { try {
await this.runs.create({ _id: `${ruleId}:${eventKey}`, at: new Date() }); await this.runs.create({ id: `${ruleId}:${eventKey}`, at: new Date() });
return true; return true;
} catch (error) { } catch (error) {
if ((error as { code?: number }).code === DUPLICATE_KEY) return false; 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> { async recordRun(id: string, error: string | null): Promise<void> {
await this.automations.updateOne( await this.automations.updateOne(
{ _id: id }, { id },
{ {
$inc: { runs: 1 }, $inc: { runs: 1 },
$set: { lastRunAt: new Date().toISOString(), lastError: error }, $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> { 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; return doc ? toProject(doc) : null;
} }
@@ -109,7 +109,7 @@ export class MongoFindProjectsRepository extends FindProjectsRepository {
userId: string, userId: string,
): Promise<ProjectRole | null> { ): Promise<ProjectRole | null> {
const doc = await this.projects const doc = await this.projects
.findOne({ _id: projectId, memberIds: userId }, { members: 1 }) .findOne({ id: projectId, memberIds: userId }, { members: 1 })
.lean<Pick<ProjectDoc, 'members'>>(); .lean<Pick<ProjectDoc, 'members'>>();
return doc?.members.find((m) => m.user.id === userId)?.role ?? null; 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> { 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; return doc ? issueFromDoc(doc) : null;
} }
@@ -136,7 +136,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
async findByIds(ids: string[]): Promise<IssueReadModel[]> { async findByIds(ids: string[]): Promise<IssueReadModel[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];
const docs = await this.issues const docs = await this.issues
.find({ _id: { $in: ids } }) .find({ id: { $in: ids } })
.lean<IssueDoc[]>(); .lean<IssueDoc[]>();
return docs.map(issueFromDoc); 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. // Only the top of each tree: a ticket with subtasks under an epic is not one.
const docs = await this.issues const docs = await this.issues
.find({ .find({
_id: { $in: [...progress.keys()] }, id: { $in: [...progress.keys()] },
'parent.id': null, 'parent.id': null,
trashedAt: null, trashedAt: null,
}) })
.sort({ number: 1 }) .sort({ number: 1 })
.lean<IssueDoc[]>(); .lean<IssueDoc[]>();
return docs.map((doc) => { return docs.map((doc) => {
const { _id: _ignored, ...row } = progress.get(doc._id) ?? { const { _id: _ignored, ...row } = progress.get(doc.id) ?? {
_id: doc._id, _id: doc.id,
total: 0, total: 0,
finished: 0, finished: 0,
storyPoints: 0, storyPoints: 0,
@@ -456,7 +456,7 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
} }
async findById(id: string): Promise<CommentReadModel | null> { 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; return doc ? commentFromDoc(doc) : null;
} }
@@ -496,7 +496,7 @@ export class MongoFindReleasesRepository extends FindReleasesRepository {
} }
async findById(id: string): Promise<ReleaseReadModel | null> { 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; return doc ? fromDoc(doc) : null;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,12 +64,12 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
async remove(projectId: string) { async remove(projectId: string) {
await Promise.all([ await Promise.all([
this.projects.deleteOne({ _id: projectId }), this.projects.deleteOne({ id: projectId }),
this.activity.deleteMany({ projectId }), this.activity.deleteMany({ projectId }),
this.timeline.deleteMany({ projectId }), this.timeline.deleteMany({ projectId }),
// Views of the board of a project that is gone. // Views of the board of a project that is gone.
this.savedFilters.deleteMany({ projectId }), this.savedFilters.deleteMany({ projectId }),
this.gitIntegrations.deleteOne({ _id: projectId }), this.gitIntegrations.deleteOne({ id: projectId }),
this.outgoingWebhooks.deleteMany({ projectId }), this.outgoingWebhooks.deleteMany({ projectId }),
this.automations.deleteMany({ projectId }), this.automations.deleteMany({ projectId }),
]); ]);
@@ -102,7 +102,7 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
async remove(issueId: string) { async remove(issueId: string) {
await Promise.all([ await Promise.all([
this.issues.deleteOne({ _id: issueId }), this.issues.deleteOne({ id: issueId }),
this.issues.updateMany( this.issues.updateMany(
{ {
$or: [ $or: [
@@ -138,21 +138,21 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
async addWatcher(issueId: string, user: UserRefReadModel) { async addWatcher(issueId: string, user: UserRefReadModel) {
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId, 'watchers.id': { $ne: user.id } }, { id: issueId, 'watchers.id': { $ne: user.id } },
{ $push: { watchers: user } }, { $push: { watchers: user } },
); );
} }
async removeWatcher(issueId: string, userId: string) { async removeWatcher(issueId: string, userId: string) {
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId }, { id: issueId },
{ $pull: { watchers: { id: userId } } }, { $pull: { watchers: { id: userId } } },
); );
} }
async addChecklistItem(issueId: string, item: ChecklistItemReadModel) { async addChecklistItem(issueId: string, item: ChecklistItemReadModel) {
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId, 'checklist.id': { $ne: item.id } }, { id: issueId, 'checklist.id': { $ne: item.id } },
{ $push: { checklist: item } }, { $push: { checklist: item } },
); );
} }
@@ -170,14 +170,14 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
); );
if (Object.keys(set).length === 0) return; if (Object.keys(set).length === 0) return;
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId, 'checklist.id': itemId }, { id: issueId, 'checklist.id': itemId },
{ $set: set }, { $set: set },
); );
} }
async removeChecklistItem(issueId: string, itemId: string) { async removeChecklistItem(issueId: string, itemId: string) {
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId }, { id: issueId },
{ $pull: { checklist: { id: itemId } } }, { $pull: { checklist: { id: itemId } } },
); );
} }
@@ -185,11 +185,11 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
async putDevLink(issueId: string, link: DevLink) { async putDevLink(issueId: string, link: DevLink) {
// Same URL: the newer report replaces the older one. // Same URL: the newer report replaces the older one.
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId }, { id: issueId },
{ $pull: { devLinks: { url: link.url } } }, { $pull: { devLinks: { url: link.url } } },
); );
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId }, { id: issueId },
{ $push: { devLinks: { $each: [link], $slice: -MAX_DEV_LINKS } } }, { $push: { devLinks: { $each: [link], $slice: -MAX_DEV_LINKS } } },
); );
} }
@@ -203,14 +203,14 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
async addAttachment(issueId: string, attachment: AttachmentReadModel) { async addAttachment(issueId: string, attachment: AttachmentReadModel) {
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId, 'attachments.id': { $ne: attachment.id } }, { id: issueId, 'attachments.id': { $ne: attachment.id } },
{ $push: { attachments: attachment } }, { $push: { attachments: attachment } },
); );
} }
async removeAttachment(issueId: string, attachmentId: string) { async removeAttachment(issueId: string, attachmentId: string) {
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId }, { id: issueId },
{ $pull: { attachments: { id: attachmentId } } }, { $pull: { attachments: { id: attachmentId } } },
); );
} }
@@ -219,7 +219,7 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks'; const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
await this.issues.updateOne( await this.issues.updateOne(
{ {
_id: issueId, id: issueId,
[field]: { [field]: {
$not: { $elemMatch: { type: link.type, issueId: link.issueId } }, $not: { $elemMatch: { type: link.type, issueId: link.issueId } },
}, },
@@ -234,13 +234,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
) { ) {
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks'; const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
await this.issues.updateOne( await this.issues.updateOne(
{ _id: issueId }, { id: issueId },
{ $pull: { [field]: { type: link.type, issueId: link.issueId } } }, { $pull: { [field]: { type: link.type, issueId: link.issueId } } },
); );
} }
async setCommentCount(issueId: string, commentCount: number) { async setCommentCount(issueId: string, commentCount: number) {
await this.issues.updateOne({ _id: issueId }, { $set: { commentCount } }); await this.issues.updateOne({ id: issueId }, { $set: { commentCount } });
} }
async recategorize( async recategorize(
@@ -289,7 +289,7 @@ export class MongoUpsertMaterializedCommentRepository extends UpsertMaterialized
} }
async remove(id: string) { 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) { 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> { async insert({ id, ...fields }: ActivityReadModel): Promise<boolean> {
const result = await this.activity.updateOne( const result = await this.activity.updateOne(
{ _id: id }, { id },
{ $setOnInsert: fields }, { $setOnInsert: fields },
{ upsert: true }, { upsert: true },
); );
@@ -345,7 +345,7 @@ export class MongoUpsertMaterializedTimelineRepository extends UpsertMaterialize
async record({ id, ...fields }: IssueTimelineEntry): Promise<void> { async record({ id, ...fields }: IssueTimelineEntry): Promise<void> {
await this.timeline.updateOne( await this.timeline.updateOne(
{ _id: id }, { id },
{ $setOnInsert: fields }, { $setOnInsert: fields },
{ upsert: true }, { 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 * Write-side lookup tables. The event store holds the truth; these only
* enforce uniqueness and sequences that cannot be checked per aggregate. * 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') @Entity('project_keys')
export class ProjectKeyEntity { export class ProjectKeyEntity {
@PrimaryColumn({ length: 10 }) @PrimaryGeneratedColumn('uuid')
id!: string;
@Index('uq_project_keys_key', { unique: true })
@Column({ length: 10 })
key!: string; key!: string;
@Column({ name: 'project_id', length: 64 }) @Column({ name: 'project_id', length: 64 })
@@ -16,7 +21,12 @@ export class ProjectKeyEntity {
@Entity('issue_sequences') @Entity('issue_sequences')
export class IssueSequenceEntity { 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; projectId!: string;
@Column({ name: 'last_number', type: 'int', default: 0 }) @Column({ name: 'last_number', type: 'int', default: 0 })
@@ -24,11 +34,15 @@ export class IssueSequenceEntity {
} }
@Entity('release_names') @Entity('release_names')
@Unique('uq_release_names_project_name', ['projectId', 'name'])
export class ReleaseNameEntity { export class ReleaseNameEntity {
@PrimaryColumn({ name: 'project_id', length: 64 }) @PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'project_id', length: 64 })
projectId!: string; projectId!: string;
@PrimaryColumn({ length: 60 }) @Column({ length: 60 })
name!: string; name!: string;
@Column({ name: 'release_id', length: 64 }) @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 { CqrsModule } from '@nestjs/cqrs';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
@@ -54,8 +54,6 @@ import {
} from './mongo/upsert-materialized.repositories'; } from './mongo/upsert-materialized.repositories';
import { StatusCategoryBackfill } from './mongo/status-category.backfill'; import { StatusCategoryBackfill } from './mongo/status-category.backfill';
import { RankBackfill } from './mongo/rank.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 { LOOKUP_ENTITIES } from './postgres/lookup.entities';
import { import {
PostgresIssueNumbersRepository, PostgresIssueNumbersRepository,
@@ -158,13 +156,16 @@ const PORTS = [
TypeOrmModule.forFeature(LOOKUP_ENTITIES), TypeOrmModule.forFeature(LOOKUP_ENTITIES),
MongooseModule.forFeature(READ_MODEL_SCHEMAS), MongooseModule.forFeature(READ_MODEL_SCHEMAS),
], ],
providers: [ providers: [...PORTS, StatusCategoryBackfill, RankBackfill],
...PORTS,
StatusCategoryBackfill,
RankBackfill,
ReminderScheduler,
TrashScheduler,
],
exports: PORTS.map((port) => port.provide), 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, OnApplicationShutdown,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { CommandBus } from '@nestjs/cqrs'; import { NotificationsService } from '@task-stream/api/tracker/application';
import { SendDueRemindersCommand } from '@task-stream/api/tracker/application';
const HOUR = 3_600_000; const HOUR = 3_600_000;
@@ -34,7 +33,7 @@ export class ReminderScheduler
private timers: ReturnType<typeof setTimeout>[] = []; private timers: ReturnType<typeof setTimeout>[] = [];
constructor( constructor(
private readonly commandBus: CommandBus, private readonly notifications: NotificationsService,
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
@@ -56,8 +55,8 @@ export class ReminderScheduler
const timeZone = const timeZone =
this.config.get<string>('REMINDERS_TIMEZONE') || 'Europe/Madrid'; this.config.get<string>('REMINDERS_TIMEZONE') || 'Europe/Madrid';
try { try {
const sent: number = await this.commandBus.execute( const sent = await this.notifications.sendDueReminders(
new SendDueRemindersCommand(dayIn(new Date(), timeZone)), dayIn(new Date(), timeZone),
); );
if (sent) this.logger.log(`Due-date reminders checked (${sent})`); if (sent) this.logger.log(`Due-date reminders checked (${sent})`);
} catch (error) { } catch (error) {

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
IssueLink, IssueLink,
IssuesGateway, IssuesGateway,
IssueLinkType, IssueLinkType,
IssueSummary,
UpdateIssueInput, UpdateIssueInput,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs'; 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. */ /** 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 { setParent(parentKey: string | null): void {
this.withIssue((issueId) => this.withIssue((issueId) =>
this.dispatch.parentRequested({ issueId, parentKey }), this.dispatch.parentRequested({ issueId, parentKey }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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