Compare commits
2 Commits
10c8f21ff4
...
1550332d41
| Author | SHA1 | Date | |
|---|---|---|---|
| 1550332d41 | |||
| 2c501a50d0 |
@@ -24,11 +24,16 @@ import { HealthController } from './health.controller';
|
||||
* Composition root. Each bounded context is assembled outside-in:
|
||||
* presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters)
|
||||
*/
|
||||
const identity = IdentityPresentersModule.withApplication(
|
||||
IdentityApplicationModule.withInfrastructure(IdentityInfrastructureModule),
|
||||
const identityApplication = IdentityApplicationModule.withInfrastructure(
|
||||
IdentityInfrastructureModule,
|
||||
);
|
||||
const identity = IdentityPresentersModule.withApplication(identityApplication);
|
||||
// The tracker's user directory asks the identity context (IdentityService):
|
||||
// the same module, so one identity context serves both.
|
||||
const tracker = TrackerPresentersModule.withApplication(
|
||||
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule),
|
||||
TrackerApplicationModule.withInfrastructure(
|
||||
TrackerInfrastructureModule.withIdentity(identityApplication),
|
||||
),
|
||||
);
|
||||
|
||||
@Module({
|
||||
@@ -47,6 +52,8 @@ const tracker = TrackerPresentersModule.withApplication(
|
||||
url: config.getOrThrow<string>('POSTGRES_URL'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'),
|
||||
// uuid ids come from gen_random_uuid() (core since PostgreSQL 13).
|
||||
uuidExtension: 'pgcrypto',
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
@@ -23,14 +23,23 @@ flowchart LR
|
||||
| `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` |
|
||||
| `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` |
|
||||
| `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` |
|
||||
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, páginas Angular) | `domain`, `application`, `presenters` |
|
||||
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, temporizadores, páginas Angular) | `domain`, `application`, `presenters` |
|
||||
|
||||
Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`).
|
||||
|
||||
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
|
||||
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
|
||||
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
|
||||
- `presenters` nunca importa `infrastructure` ni usa los buses CQRS: habla con los `*.service.ts` (API) o con las facades (web).
|
||||
- Los buses CQRS (`CommandBus`, `QueryBus`) solo los usan los `*.service.ts` de `application`; todo lo demás pasa por
|
||||
un servicio: resolvers, controllers, temporizadores (recordatorios, papelera; por eso viven en `presenters`), el
|
||||
handler de eventos de las automatizaciones y el directorio de usuarios del tracker (`IdentityService`). La única
|
||||
excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas.
|
||||
- Los comandos son atómicos: un handler no ejecuta otros comandos. Lo que encadena varios (duplicar un ticket, la
|
||||
edición en bloque, importar un CSV, las propuestas, vaciar la papelera) es un método del servicio, que ejecuta los
|
||||
comandos uno a uno con su bus. Así no hay ciclos entre servicios y handlers, ni `forwardRef`.
|
||||
- Sin módulos globales entre contextos: el tracker recibe el módulo de aplicación de identidad en la raíz de composición
|
||||
(`TrackerInfrastructureModule.withIdentity(...)`), el mismo con el que se monta ese contexto.
|
||||
- `presenters` nunca importa `infrastructure`: habla con los `*.service.ts` (API) o con las facades (web).
|
||||
- `infrastructure` nunca importa `presenters`.
|
||||
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web
|
||||
no depende de la API (y viceversa).
|
||||
@@ -103,6 +112,9 @@ sequenceDiagram
|
||||
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
|
||||
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
|
||||
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
|
||||
- **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una
|
||||
restricción aparte (la clave, el proyecto, proyecto + nombre). En `events`, el orden global con el que se reenvían los
|
||||
eventos va en `seq` (`bigserial`), porque un uuid no se ordena; `stream_id` es el id del agregado.
|
||||
|
||||
### De escritura a lectura: Redis
|
||||
|
||||
@@ -122,6 +134,13 @@ sequenceDiagram
|
||||
que reentregar un evento no tiene efecto.
|
||||
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
|
||||
`domain/read-models`.
|
||||
- **Esquemas**: clases con `@Schema()` y `@Prop()` de `@nestjs/mongoose` (`read-model.schemas.ts`,
|
||||
`user.schema.ts`), en `strict: false`: cada documento guarda el read model entero y las props declaradas son las que
|
||||
tienen índice o una forma que conviene fijar.
|
||||
- **Ids**: cada documento tiene `_id` (el `ObjectId` de Mongo, que no sale de la base de datos) e `id` con índice
|
||||
único, que es el id del dominio: el mismo uuid que el `stream_id` del agregado en Postgres. Todas las consultas van
|
||||
por `id`. En usuarios es el id de Pireon; en actividad, historial y avisos, uno compuesto que hace idempotente la
|
||||
proyección.
|
||||
|
||||
### Tiempo real
|
||||
|
||||
|
||||
@@ -65,3 +65,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
||||
- Paquete inicial de la web de 2,13 MB a 1,88 MB con `@pi/ui` 0.0.4, que ya no registra Chart.js al importarse ni
|
||||
depende de `lodash`.
|
||||
- Descripciones y comentarios con editor visual (guardado en Markdown), con menciones y capturas pegadas.
|
||||
- Ids: `uuid` en todas las tablas de Postgres (el orden de los eventos en `seq`) y, en Mongo, `id` con el uuid del
|
||||
dominio más el `_id` `ObjectId` propio.
|
||||
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -16,20 +16,20 @@ export class MongoUserRegistry extends UserRegistry {
|
||||
|
||||
async upsert(user: User): Promise<void> {
|
||||
await this.users.updateOne(
|
||||
{ _id: user.id },
|
||||
{ id: user.id },
|
||||
{ $set: { name: user.name, email: user.email, isActive: user.isActive } },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
const doc = await this.users.findById(id).lean();
|
||||
const doc = await this.users.findOne({ id }).lean();
|
||||
return doc ? this.toUser(doc) : null;
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<User[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const docs = await this.users.find({ _id: { $in: ids } }).lean();
|
||||
const docs = await this.users.find({ id: { $in: ids } }).lean();
|
||||
return docs.map((d) => this.toUser(d));
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export class MongoUserRegistry extends UserRegistry {
|
||||
|
||||
private toUser(doc: UserDocument): User {
|
||||
return this.factory.restore({
|
||||
id: doc._id,
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
email: doc.email,
|
||||
isActive: doc.isActive,
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
|
||||
@Schema({ collection: 'users', timestamps: true, versionKey: false })
|
||||
export class UserDocument {
|
||||
@Prop({ type: String, required: true })
|
||||
_id!: string;
|
||||
/** The user's id at Pireon; `_id` is Mongo's own ObjectId. */
|
||||
@Prop({ type: String, required: true, unique: true })
|
||||
id!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
name!: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Generated,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
@@ -13,12 +14,21 @@ import {
|
||||
*/
|
||||
@Entity('events')
|
||||
@Unique('uq_events_stream_position', ['streamId', 'position'])
|
||||
@Index('idx_events_unpublished', ['id'], { where: '"published_at" IS NULL' })
|
||||
@Index('idx_events_unpublished', ['seq'], { where: '"published_at" IS NULL' })
|
||||
export class EventEntity {
|
||||
/** Global order of the store, used to relay events in sequence. */
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
/** The event's own id. */
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
/**
|
||||
* Global order of the store, used to relay events in sequence. A uuid has
|
||||
* no reliable order, so it is kept apart.
|
||||
*/
|
||||
@Index('uq_events_seq', { unique: true })
|
||||
@Column({ type: 'bigint' })
|
||||
@Generated('increment')
|
||||
seq!: string;
|
||||
|
||||
@Column({ name: 'stream_id', length: 64 })
|
||||
streamId!: string;
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export class EventStoreRelay
|
||||
.getRepository(EventEntity)
|
||||
.createQueryBuilder('e')
|
||||
.where('e.published_at IS NULL')
|
||||
.orderBy('e.id', 'ASC')
|
||||
.orderBy('e.seq', 'ASC')
|
||||
.limit(BATCH_SIZE)
|
||||
.setLock('pessimistic_write')
|
||||
.setOnLocked('skip_locked')
|
||||
|
||||
@@ -8,6 +8,8 @@ export * from './lib/releases.service';
|
||||
export * from './lib/changes.service';
|
||||
export * from './lib/workflows.service';
|
||||
export * from './lib/blockers.service';
|
||||
export * from './lib/bulk-change';
|
||||
export * from './lib/proposals';
|
||||
export * from './lib/proposal-quota.service';
|
||||
export * from './lib/notifications.service';
|
||||
export * from './lib/notifications';
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CreateAutomationCommand,
|
||||
DeleteAutomationCommand,
|
||||
GetAutomationsQuery,
|
||||
RunAutomationCommand,
|
||||
UpdateAutomationCommand,
|
||||
} from './automations/automations';
|
||||
import { Automation } from './ports/automations.repository';
|
||||
@@ -41,6 +42,13 @@ export class AutomationsService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs a rule on a ticket; the error, if any, as text. */
|
||||
run(automation: Automation, issueId: string): Promise<string | null> {
|
||||
return this.commandBus.execute(
|
||||
new RunAutomationCommand(automation, issueId),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, automationId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteAutomationCommand(actorId, automationId),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
EventPublisher,
|
||||
EventsHandler,
|
||||
ICommandHandler,
|
||||
IEventHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
@@ -17,17 +13,11 @@ import {
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
CommentFactory,
|
||||
Issue,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
Project,
|
||||
SYSTEM_ACTOR_ID,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import {
|
||||
@@ -42,8 +32,6 @@ import {
|
||||
AutomationConditions,
|
||||
AutomationTrigger,
|
||||
normalizeRule,
|
||||
ruleApplies,
|
||||
triggerOf,
|
||||
} from './automation-rules';
|
||||
|
||||
/** A project keeps at most this many rules. */
|
||||
@@ -314,63 +302,10 @@ export class GetAutomationsQueryHandler
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the project's rules on what people do. What rules themselves do (as
|
||||
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
|
||||
* per event, also when the event is delivered again.
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueAssignedEvent,
|
||||
CommentAddedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
)
|
||||
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly logger = new Logger(AutomationsEventHandler.name);
|
||||
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent) {
|
||||
// Git reports come as the system too, and a merge is worth reacting to.
|
||||
if (
|
||||
event.actorId === SYSTEM_ACTOR_ID &&
|
||||
!(event instanceof IssueDevLinkRecordedEvent)
|
||||
)
|
||||
return;
|
||||
const trigger = triggerOf(event);
|
||||
if (!trigger) return;
|
||||
const rules = await this.automations.findActive(event.projectId);
|
||||
if (!rules.length) return;
|
||||
const issue = await this.access.live(trigger.issueId, Issue);
|
||||
if (!issue) return;
|
||||
for (const rule of rules) {
|
||||
if (!ruleApplies(rule, trigger, issue)) continue;
|
||||
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
|
||||
if (!(await this.automations.claimRun(rule.id, key))) continue;
|
||||
let error: string | null;
|
||||
try {
|
||||
error = await this.commandBus.execute(
|
||||
new RunAutomationCommand(rule, trigger.issueId),
|
||||
);
|
||||
} catch (failure) {
|
||||
error = failure instanceof Error ? failure.message : String(failure);
|
||||
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
|
||||
}
|
||||
await this.automations.recordRun(rule.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMATION_HANDLERS = [
|
||||
CreateAutomationCommandHandler,
|
||||
UpdateAutomationCommandHandler,
|
||||
DeleteAutomationCommandHandler,
|
||||
RunAutomationCommandHandler,
|
||||
GetAutomationsQueryHandler,
|
||||
AutomationsEventHandler,
|
||||
];
|
||||
|
||||
23
libs/api/tracker/application/src/lib/bulk-change.ts
Normal file
23
libs/api/tracker/application/src/lib/bulk-change.ts
Normal 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 }[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { DuplicateIssueCommandHandler } from './duplicate-issue.command-handler';
|
||||
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { SubmitProposalCommandHandler } from './submit-proposal.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler';
|
||||
@@ -36,7 +34,6 @@ import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler
|
||||
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
|
||||
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
|
||||
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
|
||||
import { TRASH_HANDLERS } from './trash';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
@@ -46,10 +43,8 @@ import {
|
||||
} from './change-checklist.command-handler';
|
||||
|
||||
export const COMMAND_HANDLERS = [
|
||||
SubmitProposalCommandHandler,
|
||||
VoteIssueCommandHandler,
|
||||
ReactToCommentCommandHandler,
|
||||
DuplicateIssueCommandHandler,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
@@ -85,7 +80,6 @@ export const COMMAND_HANDLERS = [
|
||||
RankIssueCommandHandler,
|
||||
WriteReleaseNotesCommandHandler,
|
||||
...SPRINT_COMMAND_HANDLERS,
|
||||
BulkUpdateIssuesCommandHandler,
|
||||
...TRASH_HANDLERS,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
|
||||
@@ -75,10 +75,8 @@ import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { AttachmentStorage } from '../ports/attachment-storage';
|
||||
import { AddCommentCommand } from './add-comment.command';
|
||||
import { DuplicateIssueCommand } from './duplicate-issue.command';
|
||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { UpdateProjectCommand } from './update-project.command';
|
||||
import { SubmitProposalCommand } from './submit-proposal.command';
|
||||
import { VoteIssueCommand } from './vote-issue.command';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
import { AssignIssueCommand } from './assign-issue.command';
|
||||
@@ -87,6 +85,8 @@ import { DetachFileCommand } from './detach-file.command';
|
||||
import { WatchIssueCommand } from './watch-issue.command';
|
||||
import { ChangeIssueStatusCommand } from './change-issue-status.command';
|
||||
import { COMMAND_HANDLERS } from './command-handlers';
|
||||
import { IssuesService } from '../issues.service';
|
||||
import { ProjectsService } from '../projects.service';
|
||||
import { CommandResult } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { CreateProjectCommand } from './create-project.command';
|
||||
@@ -96,7 +96,6 @@ import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
import { RestoreIssueCommand, TrashIssueCommand } from './trash';
|
||||
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
|
||||
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
@@ -131,6 +130,9 @@ async function setup() {
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...COMMAND_HANDLERS,
|
||||
// What composes several commands lives in the services.
|
||||
IssuesService,
|
||||
ProjectsService,
|
||||
...GIT_HANDLERS,
|
||||
RunAutomationCommandHandler,
|
||||
...TRACKER_FACTORIES,
|
||||
@@ -196,6 +198,8 @@ async function setup() {
|
||||
projectViews,
|
||||
run,
|
||||
rehydrator: moduleRef.get(AggregateRehydrator),
|
||||
issues: moduleRef.get(IssuesService),
|
||||
projects: moduleRef.get(ProjectsService),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -702,7 +706,7 @@ describe('tracker commands (event sourced)', () => {
|
||||
});
|
||||
|
||||
it('changes several tickets at once and says which refused', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const { issues, run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const ids: string[] = [];
|
||||
for (const title of ['A', 'B'])
|
||||
@@ -716,13 +720,11 @@ describe('tracker commands (event sourced)', () => {
|
||||
for (const status of ['TODO', 'IN_PROGRESS'])
|
||||
await run(new ChangeIssueStatusCommand('dev', ids[1], status));
|
||||
// A is in BACKLOG: the workflow does not let it jump to review.
|
||||
const result = (await run(
|
||||
new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], {
|
||||
status: 'IN_REVIEW',
|
||||
assigneeId: 'qa',
|
||||
addLabels: ['sprint-14'],
|
||||
}),
|
||||
)) as unknown as BulkResult;
|
||||
const result = await issues.bulkUpdate('dev', [...ids, ids[1]], {
|
||||
status: 'IN_REVIEW',
|
||||
assigneeId: 'qa',
|
||||
addLabels: ['sprint-14'],
|
||||
});
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
expect(result.results.map((r) => [r.issueId, r.ok])).toEqual([
|
||||
@@ -735,9 +737,9 @@ describe('tracker commands (event sourced)', () => {
|
||||
'qa',
|
||||
['sprint-14'],
|
||||
]);
|
||||
await expect(
|
||||
run(new BulkUpdateIssuesCommand('dev', ids, {})),
|
||||
).rejects.toThrow(/Nothing to change/);
|
||||
await expect(issues.bulkUpdate('dev', ids, {})).rejects.toThrow(
|
||||
/Nothing to change/,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends tickets to the trash and back; trashed ones cannot change', async () => {
|
||||
@@ -850,7 +852,7 @@ describe('tracker commands (event sourced)', () => {
|
||||
});
|
||||
|
||||
it('duplicates what a ticket says, not where it stands', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const { issues, run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const original = await run(
|
||||
new CreateIssueCommand(
|
||||
@@ -869,7 +871,7 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
await run(new AddChecklistItemCommand('dev', original.id, 'Reproducir'));
|
||||
|
||||
const copy = await run(new DuplicateIssueCommand('qa', original.id));
|
||||
const copy = await issues.duplicate('qa', original.id);
|
||||
expect(copy.key).not.toBe(original.key);
|
||||
const issue = await rehydrator.rehydrate(copy.id, Issue);
|
||||
expect(issue).toMatchObject({
|
||||
@@ -884,9 +886,9 @@ describe('tracker commands (event sourced)', () => {
|
||||
expect(issue.checklist.map((s) => [s.text, s.done])).toEqual([
|
||||
['Reproducir', false],
|
||||
]);
|
||||
await expect(
|
||||
run(new DuplicateIssueCommand('pm', original.id)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await expect(issues.duplicate('pm', original.id)).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
|
||||
it('takes reactions from members, once each, viewers included', async () => {
|
||||
@@ -939,7 +941,7 @@ describe('tracker commands (event sourced)', () => {
|
||||
});
|
||||
|
||||
it('turns a proposal into a ticket, only where the project takes them', async () => {
|
||||
const { run, rehydrator, projectViews } = await setup();
|
||||
const { run, rehydrator, projectViews, projects } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const view = {
|
||||
id: projectId,
|
||||
@@ -949,14 +951,12 @@ describe('tracker commands (event sourced)', () => {
|
||||
};
|
||||
projectViews.set(projectId, view);
|
||||
const propose = () =>
|
||||
run(
|
||||
new SubmitProposalCommand(
|
||||
'ts',
|
||||
'BUG',
|
||||
'No carga en Firefox',
|
||||
'Pantalla en blanco',
|
||||
'Lucía',
|
||||
),
|
||||
projects.propose(
|
||||
'ts',
|
||||
'BUG',
|
||||
'No carga en Firefox',
|
||||
'Pantalla en blanco',
|
||||
'Lucía',
|
||||
);
|
||||
await expect(propose()).rejects.toThrow(ForbiddenError);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,8 @@ export * from './assign-issue.command';
|
||||
export * from './change-issue-status.command';
|
||||
export * from './change-project-member-role.command';
|
||||
export * from './create-issue.command';
|
||||
export * from './duplicate-issue.command';
|
||||
export * from './react-to-comment.command';
|
||||
export * from './vote-issue.command';
|
||||
export * from './submit-proposal.command';
|
||||
export { PROPOSAL_LABEL } from './submit-proposal.command-handler';
|
||||
export * from './create-project.command';
|
||||
export * from './create-release.command';
|
||||
export * from './delete-comment.command';
|
||||
@@ -40,5 +37,4 @@ export * from './change-checklist.command';
|
||||
export * from './rank-issue.command';
|
||||
export * from './sprint.commands';
|
||||
export * from './write-release-notes.command';
|
||||
export * from './bulk-update-issues';
|
||||
export * from './trash';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { DeleteIssueCommand } from './delete-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** Days a ticket stays in the trash before it is deleted for good. */
|
||||
@@ -31,13 +23,6 @@ export class RestoreIssueCommand extends Command<CommandResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduler only: deletes for good what went to the trash before `before`. */
|
||||
export class EmptyTrashCommand extends Command<number> {
|
||||
constructor(public readonly before: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(TrashIssueCommand)
|
||||
export class TrashIssueCommandHandler
|
||||
implements ICommandHandler<TrashIssueCommand>
|
||||
@@ -73,31 +58,7 @@ export class RestoreIssueCommandHandler
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each deletion is the usual one: comments purged, links dropped and
|
||||
* subtasks back to the top, by the sagas.
|
||||
*/
|
||||
@CommandHandler(EmptyTrashCommand)
|
||||
export class EmptyTrashCommandHandler
|
||||
implements ICommandHandler<EmptyTrashCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async execute(command: EmptyTrashCommand): Promise<number> {
|
||||
const old = await this.findIssues.findTrashedBefore(command.before);
|
||||
for (const issue of old)
|
||||
await this.commandBus.execute(
|
||||
new DeleteIssueCommand(SYSTEM_ACTOR_ID, issue.id),
|
||||
);
|
||||
return old.length;
|
||||
}
|
||||
}
|
||||
|
||||
export const TRASH_HANDLERS = [
|
||||
TrashIssueCommandHandler,
|
||||
RestoreIssueCommandHandler,
|
||||
EmptyTrashCommandHandler,
|
||||
];
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
EpicReadModel,
|
||||
IssueDetails,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
|
||||
import { AssignIssueCommand } from './commands/assign-issue.command';
|
||||
import { DuplicateIssueCommand } from './commands/duplicate-issue.command';
|
||||
import { VoteIssueCommand } from './commands/vote-issue.command';
|
||||
import {
|
||||
AttachFileCommand,
|
||||
@@ -19,13 +22,12 @@ import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-issue.command';
|
||||
import { DeleteIssueCommand } from './commands/delete-issue.command';
|
||||
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash';
|
||||
import { SetIssueSprintCommand } from './commands/sprint.commands';
|
||||
import { WriteAccess } from './commands/write-access';
|
||||
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||
import { GetTrashQuery } from './queries/get-trash.query';
|
||||
import {
|
||||
BulkChange,
|
||||
BulkResult,
|
||||
BulkUpdateIssuesCommand,
|
||||
} from './commands/bulk-update-issues';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
@@ -62,6 +64,10 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
|
||||
releaseId?: string | null;
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
parentId?: string | null;
|
||||
/** Imports only: start in this (active) status instead of the first one. */
|
||||
initialStatus?: string | null;
|
||||
/** Fill in the type's template (false when the content comes with it). */
|
||||
useTemplate?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueLinkInput {
|
||||
@@ -75,6 +81,8 @@ export class IssuesService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
) {}
|
||||
|
||||
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
|
||||
@@ -85,6 +93,8 @@ export class IssuesService {
|
||||
assigneeId,
|
||||
releaseId,
|
||||
parentId,
|
||||
initialStatus,
|
||||
useTemplate,
|
||||
...details
|
||||
} = input;
|
||||
return this.commandBus.execute(
|
||||
@@ -97,6 +107,8 @@ export class IssuesService {
|
||||
assigneeId,
|
||||
releaseId,
|
||||
parentId,
|
||||
initialStatus,
|
||||
useTemplate ?? true,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -241,15 +253,55 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
/** The same change on several tickets; each one may succeed or not. */
|
||||
bulkUpdate(
|
||||
/**
|
||||
* The same change on several tickets. Each ticket goes through the normal
|
||||
* commands (permissions, workflow, open sprints and releases), one after
|
||||
* another; one refusing does not stop the rest.
|
||||
*/
|
||||
async bulkUpdate(
|
||||
actorId: string,
|
||||
issueIds: string[],
|
||||
change: BulkChange,
|
||||
): Promise<BulkResult> {
|
||||
return this.commandBus.execute(
|
||||
new BulkUpdateIssuesCommand(actorId, issueIds, change),
|
||||
const ids = [...new Set(issueIds)];
|
||||
assertThat(ids.length > 0, 'Pick at least one ticket');
|
||||
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
|
||||
assertThat(
|
||||
Object.values(change).some((v) => v !== undefined),
|
||||
'Nothing to change',
|
||||
);
|
||||
const result: BulkResult = { updated: 0, results: [] };
|
||||
for (const issueId of ids) {
|
||||
try {
|
||||
if (change.status !== undefined)
|
||||
await this.changeStatus(actorId, issueId, change.status);
|
||||
if (change.assigneeId !== undefined)
|
||||
await this.assign(actorId, issueId, change.assigneeId);
|
||||
if (change.sprintId !== undefined)
|
||||
await this.commandBus.execute(
|
||||
new SetIssueSprintCommand(actorId, issueId, change.sprintId),
|
||||
);
|
||||
if (change.releaseId !== undefined)
|
||||
await this.setRelease(actorId, issueId, change.releaseId);
|
||||
const details: { priority?: IssuePriority; labels?: string[] } = {};
|
||||
if (change.priority) details.priority = change.priority;
|
||||
if (change.addLabels?.length) {
|
||||
const issue = await this.access.issue(issueId);
|
||||
details.labels = [...issue.labels, ...change.addLabels];
|
||||
}
|
||||
if (Object.keys(details).length)
|
||||
await this.update(actorId, issueId, details);
|
||||
result.updated++;
|
||||
result.results.push({ issueId, ok: true, error: null });
|
||||
} catch (error) {
|
||||
result.results.push({
|
||||
issueId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** To the trash (whoever opened it, or a manager); back with `restore`. */
|
||||
@@ -257,6 +309,22 @@ export class IssuesService {
|
||||
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
/** Deletes it for good (the trash is emptied this way). */
|
||||
delete(actorId: string, issueId: string): Promise<void> {
|
||||
return this.commandBus.execute(new DeleteIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes for good what went to the trash before `before`; how many. Each
|
||||
* deletion is the usual one: comments purged, links dropped and subtasks
|
||||
* back to the top, by the sagas.
|
||||
*/
|
||||
async emptyTrash(before: string): Promise<number> {
|
||||
const old = await this.findIssues.findTrashedBefore(before);
|
||||
for (const issue of old) await this.delete(SYSTEM_ACTOR_ID, issue.id);
|
||||
return old.length;
|
||||
}
|
||||
|
||||
restore(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
|
||||
}
|
||||
@@ -266,9 +334,35 @@ export class IssuesService {
|
||||
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on));
|
||||
}
|
||||
|
||||
/** A new ticket like this one (what it says, not where it stands). */
|
||||
duplicate(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new DuplicateIssueCommand(actorId, issueId));
|
||||
/**
|
||||
* A new ticket like this one: what it says (type, title, description,
|
||||
* area, priority, labels, Figma, estimate, dates, parent and checklist
|
||||
* steps, unticked), not where it stands. The copy starts in the first
|
||||
* status, unassigned, out of sprints and releases, without comments or
|
||||
* files; the copy's key comes in the result.
|
||||
*/
|
||||
async duplicate(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(issueId, actorId);
|
||||
const copy = await this.create(actorId, {
|
||||
projectId: issue.projectId,
|
||||
type: issue.type,
|
||||
title: `${issue.title} (copia)`.slice(0, 255),
|
||||
description: issue.description,
|
||||
area: issue.area,
|
||||
priority: issue.priority,
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
startDate: issue.startDate,
|
||||
dueDate: issue.dueDate,
|
||||
parentId: issue.parentId,
|
||||
// The original's text is the content, not the type's template.
|
||||
useTemplate: false,
|
||||
});
|
||||
let result = copy;
|
||||
for (const step of issue.checklist)
|
||||
result = await this.addChecklistItem(actorId, copy.id, step.text);
|
||||
return { ...result, key: copy.key };
|
||||
}
|
||||
|
||||
trashed(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { SendDueRemindersCommand } from './notifications/reminders';
|
||||
import {
|
||||
MarkNotificationsReadCommand,
|
||||
UpdateNotificationSettingsCommand,
|
||||
@@ -36,6 +37,11 @@ export class NotificationsService {
|
||||
}
|
||||
|
||||
/** `ids` omitted: everything. */
|
||||
/** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */
|
||||
sendDueReminders(day: string): Promise<number> {
|
||||
return this.commandBus.execute(new SendDueRemindersCommand(day));
|
||||
}
|
||||
|
||||
markRead(userId: string, ids?: string[] | null): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TrackerEvent,
|
||||
withoutMentionMarkup,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
|
||||
import { PROPOSAL_LABEL } from '../proposals';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import {
|
||||
ProposalKind,
|
||||
SubmitProposalCommand,
|
||||
} from './commands/submit-proposal.command';
|
||||
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssuePriority,
|
||||
ProjectAnalyticsReadModel,
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
ProjectSettings,
|
||||
ProjectStatsReadModel,
|
||||
ReleaseReadModel,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { CreateIssueCommand } from './commands/create-issue.command';
|
||||
import { WriteAccess } from './commands/write-access';
|
||||
import { FindProjectsRepository } from './ports/find-projects.repository';
|
||||
import { PROPOSAL_LABEL, PROPOSAL_TYPE, ProposalKind } from './proposals';
|
||||
import { AddProjectMemberCommand } from './commands/add-project-member.command';
|
||||
import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
@@ -35,22 +42,68 @@ export class ProjectsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
/** A public project's roadmap, releases, sprint and good first tickets (no token). */
|
||||
/** A bug report or an idea from anyone, for a public project that takes them. */
|
||||
propose(
|
||||
project: string,
|
||||
/**
|
||||
* A bug report or an idea from anyone, for a public project that takes
|
||||
* them. It becomes a ticket labelled «propuesta», opened by TaskStream
|
||||
* (nobody signed in) in the first status, as a Bug or a Feature (or the
|
||||
* first active type if the project has neither), signed with the name
|
||||
* they gave, if any.
|
||||
*/
|
||||
async propose(
|
||||
projectIdOrKey: string,
|
||||
kind: ProposalKind,
|
||||
title: string,
|
||||
description: string,
|
||||
author: string | null,
|
||||
): Promise<CommandResult> {
|
||||
const found =
|
||||
(await this.findProjects.findById(projectIdOrKey)) ??
|
||||
(await this.findProjects.findByKey(projectIdOrKey.toUpperCase()));
|
||||
if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey);
|
||||
if (!found.acceptsProposals)
|
||||
throw new ForbiddenError(`${found.key} does not take proposals`);
|
||||
|
||||
const cleanTitle = title.trim();
|
||||
assertThat(
|
||||
cleanTitle.length >= 3 && cleanTitle.length <= 150,
|
||||
'A proposal needs a title of 3 to 150 characters',
|
||||
);
|
||||
const text = description.trim();
|
||||
assertThat(text.length <= 5000, 'A proposal is 5000 characters at most');
|
||||
|
||||
const project = await this.access.project(found.id);
|
||||
const active = project.settings.issueTypes.filter((t) => !t.archived);
|
||||
const type =
|
||||
active.find((t) => t.id === PROPOSAL_TYPE[kind])?.id ?? active[0]?.id;
|
||||
const signer = author?.trim().slice(0, 80);
|
||||
const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`;
|
||||
|
||||
return this.commandBus.execute(
|
||||
new SubmitProposalCommand(project, kind, title, description, author),
|
||||
new CreateIssueCommand(
|
||||
SYSTEM_ACTOR_ID,
|
||||
project.id,
|
||||
type,
|
||||
cleanTitle,
|
||||
{
|
||||
description: text ? `${text}\n\n${signature}` : signature,
|
||||
labels: [PROPOSAL_LABEL],
|
||||
priority: IssuePriority.MEDIUM,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
// What they wrote is the content, not the type's template.
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A public project's roadmap, releases, sprint and good first tickets (no token). */
|
||||
showcase(project: string): Promise<Showcase> {
|
||||
return this.queryBus.execute(new GetShowcaseQuery(project));
|
||||
}
|
||||
|
||||
10
libs/api/tracker/application/src/lib/proposals.ts
Normal file
10
libs/api/tracker/application/src/lib/proposals.ts
Normal 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',
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
|
||||
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
|
||||
import { PROPOSAL_LABEL } from '../proposals';
|
||||
|
||||
/** Tickets with this label are offered to newcomers on public pages. */
|
||||
export const GOOD_FIRST_LABEL = 'good-first-issue';
|
||||
|
||||
@@ -43,6 +43,7 @@ import { TransferService } from './transfer.service';
|
||||
import { TRANSFER_HANDLERS } from './transfer/import-export';
|
||||
import { AutomationsService } from './automations.service';
|
||||
import { AUTOMATION_HANDLERS } from './automations/automations';
|
||||
import { AutomationsEventHandler } from './automations/automations.event-handler';
|
||||
|
||||
/** Notifications: derived from events, kept and dismissed per person. */
|
||||
const NOTIFICATIONS = [
|
||||
@@ -97,6 +98,7 @@ const SERVICES = [
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
...AUTOMATION_HANDLERS,
|
||||
AutomationsEventHandler,
|
||||
],
|
||||
exports: SERVICES,
|
||||
})
|
||||
|
||||
@@ -1,31 +1,115 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { WriteAccess } from './commands/write-access';
|
||||
import { IssuesService } from './issues.service';
|
||||
import { FindProjectsRepository } from './ports/find-projects.repository';
|
||||
import {
|
||||
ExportIssuesQuery,
|
||||
ImportIssuesCommand,
|
||||
ImportReport,
|
||||
MAX_IMPORT_MB,
|
||||
} from './transfer/import-export';
|
||||
import { planImport } from './transfer/transfer';
|
||||
|
||||
/** Tickets in and out as CSV (TaskStream's own, or a Jira export). */
|
||||
@Injectable()
|
||||
export class TransferService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly issues: IssuesService,
|
||||
) {}
|
||||
|
||||
exportCsv(viewerId: string | null, projectId: string): Promise<string> {
|
||||
return this.queryBus.execute(new ExportIssuesQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
importCsv(
|
||||
/**
|
||||
* Creates the tickets of a CSV one by one (each a normal ticket creation,
|
||||
* with its own events), then places them under their parents. Owners and
|
||||
* admins. With `dryRun`, only tells what would happen.
|
||||
*/
|
||||
async importCsv(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
csv: string,
|
||||
dryRun: boolean,
|
||||
): Promise<ImportReport> {
|
||||
return this.commandBus.execute(
|
||||
new ImportIssuesCommand(actorId, projectId, csv, dryRun),
|
||||
);
|
||||
(await this.access.project(projectId)).assertCanManage(actorId);
|
||||
if (Buffer.byteLength(csv) > MAX_IMPORT_MB * 1024 * 1024)
|
||||
throw new BusinessRuleError(
|
||||
`The file is larger than ${MAX_IMPORT_MB} MB`,
|
||||
);
|
||||
const project = await this.findProjects.findById(projectId);
|
||||
if (!project) throw new BusinessRuleError('The project is not ready yet');
|
||||
const plan = planImport(csv, project);
|
||||
const report: ImportReport = {
|
||||
format: plan.format,
|
||||
dryRun: dryRun,
|
||||
rows: plan.rows.length,
|
||||
created: 0,
|
||||
keys: [],
|
||||
skipped: [...plan.skipped],
|
||||
warnings: [...plan.warnings],
|
||||
};
|
||||
if (dryRun) return report;
|
||||
|
||||
const created = new Map<string, string>();
|
||||
const ids = new Map<number, string>();
|
||||
for (const row of plan.rows) {
|
||||
try {
|
||||
const result: CommandResult = await this.issues.create(actorId, {
|
||||
projectId: project.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
priority: row.priority,
|
||||
labels: row.labels,
|
||||
storyPoints: row.storyPoints,
|
||||
startDate: row.startDate,
|
||||
dueDate: row.dueDate,
|
||||
assigneeId: row.assigneeId,
|
||||
initialStatus: row.status,
|
||||
// The file brings the content, not the type's template.
|
||||
useTemplate: false,
|
||||
});
|
||||
report.created++;
|
||||
report.keys.push(result.key ?? result.id);
|
||||
ids.set(row.line, result.id);
|
||||
for (const key of row.sourceKeys) created.set(key, result.id);
|
||||
if (result.key) created.set(result.key, result.id);
|
||||
} catch (error) {
|
||||
report.skipped.push({
|
||||
line: row.line,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Parents last: they may come after their children in the file.
|
||||
for (const row of plan.rows) {
|
||||
const id = ids.get(row.line);
|
||||
const parent = row.parentSource
|
||||
? created.get(row.parentSource)
|
||||
: undefined;
|
||||
if (!id || !row.parentSource) continue;
|
||||
if (!parent) {
|
||||
report.warnings.push({
|
||||
line: row.line,
|
||||
message: `Su padre (${row.parentSource}) no está en el archivo`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.issues.setParent(actorId, id, parent);
|
||||
} catch (error) {
|
||||
report.warnings.push({
|
||||
line: row.line,
|
||||
message: `No cuelga de ${row.parentSource}: ${error instanceof Error ? error.message : error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
ProjectWorkflow,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from '../commands/command-result';
|
||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||
import { SetIssueParentCommand } from '../commands/set-issue-parent.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { ImportFormat, exportIssuesCsv, planImport } from './transfer';
|
||||
import { ImportFormat, exportIssuesCsv } from './transfer';
|
||||
|
||||
/** A file is at most this many megabytes. */
|
||||
export const MAX_IMPORT_MB = 5;
|
||||
@@ -36,18 +23,6 @@ export interface ImportReport {
|
||||
warnings: { line: number; message: string }[];
|
||||
}
|
||||
|
||||
export class ImportIssuesCommand extends Command<ImportReport> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly csv: string,
|
||||
/** Plan only: tell what would happen and create nothing. */
|
||||
public readonly dryRun: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class ExportIssuesQuery extends Query<string> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
@@ -57,108 +32,6 @@ export class ExportIssuesQuery extends Query<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the tickets of a CSV one by one (each a normal ticket creation, with
|
||||
* its own events), then places them under their parents. Owners and admins.
|
||||
*/
|
||||
@CommandHandler(ImportIssuesCommand)
|
||||
export class ImportIssuesCommandHandler
|
||||
implements ICommandHandler<ImportIssuesCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async execute(command: ImportIssuesCommand): Promise<ImportReport> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
if (Buffer.byteLength(command.csv) > MAX_IMPORT_MB * 1024 * 1024)
|
||||
throw new BusinessRuleError(
|
||||
`The file is larger than ${MAX_IMPORT_MB} MB`,
|
||||
);
|
||||
const project = await this.findProjects.findById(command.projectId);
|
||||
if (!project) throw new BusinessRuleError('The project is not ready yet');
|
||||
const plan = planImport(command.csv, project);
|
||||
const report: ImportReport = {
|
||||
format: plan.format,
|
||||
dryRun: command.dryRun,
|
||||
rows: plan.rows.length,
|
||||
created: 0,
|
||||
keys: [],
|
||||
skipped: [...plan.skipped],
|
||||
warnings: [...plan.warnings],
|
||||
};
|
||||
if (command.dryRun) return report;
|
||||
|
||||
const created = new Map<string, string>();
|
||||
const ids = new Map<number, string>();
|
||||
for (const row of plan.rows) {
|
||||
try {
|
||||
const result: CommandResult = await this.commandBus.execute(
|
||||
new CreateIssueCommand(
|
||||
command.actorId,
|
||||
project.id,
|
||||
row.type,
|
||||
row.title,
|
||||
{
|
||||
description: row.description,
|
||||
priority: row.priority,
|
||||
labels: row.labels,
|
||||
storyPoints: row.storyPoints,
|
||||
startDate: row.startDate,
|
||||
dueDate: row.dueDate,
|
||||
},
|
||||
row.assigneeId,
|
||||
null,
|
||||
null,
|
||||
row.status,
|
||||
false,
|
||||
),
|
||||
);
|
||||
report.created++;
|
||||
report.keys.push(result.key ?? result.id);
|
||||
ids.set(row.line, result.id);
|
||||
for (const key of row.sourceKeys) created.set(key, result.id);
|
||||
if (result.key) created.set(result.key, result.id);
|
||||
} catch (error) {
|
||||
report.skipped.push({
|
||||
line: row.line,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Parents last: they may come after their children in the file.
|
||||
for (const row of plan.rows) {
|
||||
const id = ids.get(row.line);
|
||||
const parent = row.parentSource
|
||||
? created.get(row.parentSource)
|
||||
: undefined;
|
||||
if (!id || !row.parentSource) continue;
|
||||
if (!parent) {
|
||||
report.warnings.push({
|
||||
line: row.line,
|
||||
message: `Su padre (${row.parentSource}) no está en el archivo`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new SetIssueParentCommand(command.actorId, id, parent),
|
||||
);
|
||||
} catch (error) {
|
||||
report.warnings.push({
|
||||
line: row.line,
|
||||
message: `No cuelga de ${row.parentSource}: ${error instanceof Error ? error.message : error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(ExportIssuesQuery)
|
||||
export class ExportIssuesQueryHandler
|
||||
implements IQueryHandler<ExportIssuesQuery, string>
|
||||
@@ -182,7 +55,4 @@ export class ExportIssuesQueryHandler
|
||||
}
|
||||
}
|
||||
|
||||
export const TRANSFER_HANDLERS = [
|
||||
ImportIssuesCommandHandler,
|
||||
ExportIssuesQueryHandler,
|
||||
];
|
||||
export const TRANSFER_HANDLERS = [ExportIssuesQueryHandler];
|
||||
|
||||
@@ -3,11 +3,7 @@ import {
|
||||
SYSTEM_ACTOR_NAME,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
FindUserQuery,
|
||||
FindUsersByIdsQuery,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import {
|
||||
DirectoryUser,
|
||||
UserContact,
|
||||
@@ -15,10 +11,13 @@ import {
|
||||
UserRefs,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
|
||||
/** Anti-corruption layer: the tracker asks the identity context about users. */
|
||||
/**
|
||||
* Anti-corruption layer: the tracker asks the identity context about users,
|
||||
* through its service (the identity module is global for this).
|
||||
*/
|
||||
@Injectable()
|
||||
export class IdentityUserDirectory extends UserDirectory {
|
||||
constructor(private readonly queryBus: QueryBus) {
|
||||
constructor(private readonly identity: IdentityService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -26,9 +25,7 @@ export class IdentityUserDirectory extends UserDirectory {
|
||||
userId: string,
|
||||
accessToken?: string,
|
||||
): Promise<DirectoryUser | null> {
|
||||
const user = await this.queryBus.execute(
|
||||
new FindUserQuery(userId, accessToken),
|
||||
);
|
||||
const user = await this.identity.findUser(userId, accessToken);
|
||||
return user
|
||||
? { id: user.id, name: user.name, isActive: user.isActive }
|
||||
: null;
|
||||
@@ -37,7 +34,7 @@ export class IdentityUserDirectory extends UserDirectory {
|
||||
async contacts(userIds: string[]): Promise<UserContact[]> {
|
||||
const wanted = [...new Set(userIds.filter(Boolean))];
|
||||
if (!wanted.length) return [];
|
||||
const users = await this.queryBus.execute(new FindUsersByIdsQuery(wanted));
|
||||
const users = await this.identity.findUsers(wanted);
|
||||
return users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
@@ -47,9 +44,7 @@ export class IdentityUserDirectory extends UserDirectory {
|
||||
|
||||
async refs(userIds: (string | null | undefined)[]): Promise<UserRefs> {
|
||||
const wanted = [...new Set(userIds.filter((id): id is string => !!id))];
|
||||
const users = wanted.length
|
||||
? await this.queryBus.execute(new FindUsersByIdsQuery(wanted))
|
||||
: [];
|
||||
const users = wanted.length ? await this.identity.findUsers(wanted) : [];
|
||||
const names = new Map(users.map((u) => [u.id, u.name]));
|
||||
const of = (id: string) => ({
|
||||
id,
|
||||
|
||||
@@ -41,12 +41,14 @@ export class MongoAutomationsRepository extends AutomationsRepository {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Automation | null> {
|
||||
const doc = await this.automations.findById(id).lean<AutomationDoc>();
|
||||
const doc = await this.automations
|
||||
.findOne({ id: id })
|
||||
.lean<AutomationDoc>();
|
||||
return doc ? (fromDoc(doc) as Automation) : null;
|
||||
}
|
||||
|
||||
async insert({ id, ...rest }: Automation): Promise<void> {
|
||||
await this.automations.create({ _id: id, ...rest });
|
||||
await this.automations.create({ id, ...rest });
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -54,16 +56,16 @@ export class MongoAutomationsRepository extends AutomationsRepository {
|
||||
changes: Partial<Pick<Automation, 'name' | 'active'>>,
|
||||
): Promise<void> {
|
||||
if (Object.keys(changes).length)
|
||||
await this.automations.updateOne({ _id: id }, { $set: changes });
|
||||
await this.automations.updateOne({ id }, { $set: changes });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.automations.deleteOne({ _id: id });
|
||||
await this.automations.deleteOne({ id });
|
||||
}
|
||||
|
||||
async claimRun(ruleId: string, eventKey: string): Promise<boolean> {
|
||||
try {
|
||||
await this.runs.create({ _id: `${ruleId}:${eventKey}`, at: new Date() });
|
||||
await this.runs.create({ id: `${ruleId}:${eventKey}`, at: new Date() });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as { code?: number }).code === DUPLICATE_KEY) return false;
|
||||
@@ -73,7 +75,7 @@ export class MongoAutomationsRepository extends AutomationsRepository {
|
||||
|
||||
async recordRun(id: string, error: string | null): Promise<void> {
|
||||
await this.automations.updateOne(
|
||||
{ _id: id },
|
||||
{ id },
|
||||
{
|
||||
$inc: { runs: 1 },
|
||||
$set: { lastRunAt: new Date().toISOString(), lastError: error },
|
||||
|
||||
@@ -79,7 +79,7 @@ export class MongoFindProjectsRepository extends FindProjectsRepository {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ProjectReadModel | null> {
|
||||
const doc = await this.projects.findById(id).lean<ProjectDoc>();
|
||||
const doc = await this.projects.findOne({ id: id }).lean<ProjectDoc>();
|
||||
return doc ? toProject(doc) : null;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export class MongoFindProjectsRepository extends FindProjectsRepository {
|
||||
userId: string,
|
||||
): Promise<ProjectRole | null> {
|
||||
const doc = await this.projects
|
||||
.findOne({ _id: projectId, memberIds: userId }, { members: 1 })
|
||||
.findOne({ id: projectId, memberIds: userId }, { members: 1 })
|
||||
.lean<Pick<ProjectDoc, 'members'>>();
|
||||
return doc?.members.find((m) => m.user.id === userId)?.role ?? null;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<IssueReadModel | null> {
|
||||
const doc = await this.issues.findById(id).lean<IssueDoc>();
|
||||
const doc = await this.issues.findOne({ id: id }).lean<IssueDoc>();
|
||||
return doc ? issueFromDoc(doc) : null;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
async findByIds(ids: string[]): Promise<IssueReadModel[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const docs = await this.issues
|
||||
.find({ _id: { $in: ids } })
|
||||
.find({ id: { $in: ids } })
|
||||
.lean<IssueDoc[]>();
|
||||
return docs.map(issueFromDoc);
|
||||
}
|
||||
@@ -322,15 +322,15 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
// Only the top of each tree: a ticket with subtasks under an epic is not one.
|
||||
const docs = await this.issues
|
||||
.find({
|
||||
_id: { $in: [...progress.keys()] },
|
||||
id: { $in: [...progress.keys()] },
|
||||
'parent.id': null,
|
||||
trashedAt: null,
|
||||
})
|
||||
.sort({ number: 1 })
|
||||
.lean<IssueDoc[]>();
|
||||
return docs.map((doc) => {
|
||||
const { _id: _ignored, ...row } = progress.get(doc._id) ?? {
|
||||
_id: doc._id,
|
||||
const { _id: _ignored, ...row } = progress.get(doc.id) ?? {
|
||||
_id: doc.id,
|
||||
total: 0,
|
||||
finished: 0,
|
||||
storyPoints: 0,
|
||||
@@ -456,7 +456,7 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CommentReadModel | null> {
|
||||
const doc = await this.comments.findById(id).lean<CommentDoc>();
|
||||
const doc = await this.comments.findOne({ id: id }).lean<CommentDoc>();
|
||||
return doc ? commentFromDoc(doc) : null;
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ export class MongoFindReleasesRepository extends FindReleasesRepository {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ReleaseReadModel | null> {
|
||||
const doc = await this.releases.findById(id).lean<ReleaseDoc>();
|
||||
const doc = await this.releases.findOne({ id: id }).lean<ReleaseDoc>();
|
||||
return doc ? fromDoc(doc) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
|
||||
|
||||
async repositoriesOf(projectId: string): Promise<GitRepositoryConfig[]> {
|
||||
const doc = await this.integrations
|
||||
.findById(projectId)
|
||||
.findOne({ id: projectId })
|
||||
.lean<GitIntegrationDoc>();
|
||||
if (!doc) return [];
|
||||
if (doc.repositories) return doc.repositories;
|
||||
@@ -44,11 +44,11 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
|
||||
repositories: GitRepositoryConfig[],
|
||||
): Promise<void> {
|
||||
if (!repositories.length) {
|
||||
await this.integrations.deleteOne({ _id: projectId });
|
||||
await this.integrations.deleteOne({ id: projectId });
|
||||
return;
|
||||
}
|
||||
await this.integrations.updateOne(
|
||||
{ _id: projectId },
|
||||
{ id: projectId },
|
||||
{ $set: { repositories }, $unset: { secret: '', createdAt: '' } },
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export class MongoNotificationsRepository extends NotificationsRepository {
|
||||
|
||||
async insert({ id, ...rest }: NotificationReadModel): Promise<boolean> {
|
||||
const result = await this.notifications.updateOne(
|
||||
{ _id: id },
|
||||
{ id },
|
||||
{ $setOnInsert: rest },
|
||||
{ upsert: true },
|
||||
);
|
||||
@@ -51,21 +51,21 @@ export class MongoNotificationsRepository extends NotificationsRepository {
|
||||
|
||||
async markRead(userId: string, ids: string[] | null): Promise<void> {
|
||||
await this.notifications.updateMany(
|
||||
{ userId, readAt: null, ...(ids ? { _id: { $in: ids } } : {}) },
|
||||
{ userId, readAt: null, ...(ids ? { id: { $in: ids } } : {}) },
|
||||
{ $set: { readAt: new Date().toISOString() } },
|
||||
);
|
||||
}
|
||||
|
||||
async settings(userId: string): Promise<NotificationSettingsReadModel> {
|
||||
const doc = await this.settingsDocs
|
||||
.findById(userId)
|
||||
.findOne({ id: userId })
|
||||
.lean<NotificationSettingsDoc>();
|
||||
return { userId, email: doc?.email ?? false };
|
||||
}
|
||||
|
||||
async saveSettings(settings: NotificationSettingsReadModel): Promise<void> {
|
||||
await this.settingsDocs.updateOne(
|
||||
{ _id: settings.userId },
|
||||
{ id: settings.userId },
|
||||
{ $set: { email: settings.email } },
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
@@ -33,12 +33,14 @@ export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<OutgoingWebhook | null> {
|
||||
const doc = await this.webhooks.findById(id).lean<OutgoingWebhookDoc>();
|
||||
const doc = await this.webhooks
|
||||
.findOne({ id: id })
|
||||
.lean<OutgoingWebhookDoc>();
|
||||
return doc ? (fromDoc(doc) as OutgoingWebhook) : null;
|
||||
}
|
||||
|
||||
async insert({ id, ...rest }: OutgoingWebhook): Promise<void> {
|
||||
await this.webhooks.create({ _id: id, ...rest });
|
||||
await this.webhooks.create({ id, ...rest });
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -46,17 +48,14 @@ export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository
|
||||
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
|
||||
): Promise<void> {
|
||||
if (Object.keys(changes).length)
|
||||
await this.webhooks.updateOne({ _id: id }, { $set: changes });
|
||||
await this.webhooks.updateOne({ id }, { $set: changes });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.webhooks.deleteOne({ _id: id });
|
||||
await this.webhooks.deleteOne({ id });
|
||||
}
|
||||
|
||||
async recordDelivery(id: string, delivery: WebhookDelivery): Promise<void> {
|
||||
await this.webhooks.updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { lastDelivery: delivery } },
|
||||
);
|
||||
await this.webhooks.updateOne({ id }, { $set: { lastDelivery: delivery } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ export class RankBackfill implements OnApplicationBootstrap {
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const missing = await this.issues
|
||||
.find({ rank: { $exists: false } }, { number: 1 })
|
||||
.lean<Pick<IssueDoc, '_id' | 'number'>[]>();
|
||||
.find({ rank: { $exists: false } }, { id: 1, number: 1 })
|
||||
.lean<Pick<IssueDoc, 'id' | 'number'>[]>();
|
||||
if (missing.length === 0) return;
|
||||
const result = await this.issues.bulkWrite(
|
||||
missing.map((doc) => ({
|
||||
updateOne: {
|
||||
filter: { _id: doc._id, rank: { $exists: false } },
|
||||
filter: { id: doc.id, rank: { $exists: false } },
|
||||
update: { $set: { rank: implicitRank(doc.number) } },
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -15,11 +15,17 @@ import {
|
||||
SprintReadModel,
|
||||
implicitRank,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Schema } from 'mongoose';
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
/* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */
|
||||
|
||||
type Doc<T extends { id: string }> = Omit<T, 'id'> & { _id: string };
|
||||
/**
|
||||
* Each document keeps the domain id (a uuid, the same one the event store
|
||||
* uses) in `id`, unique; `_id` is Mongo's own ObjectId and means nothing
|
||||
* outside the database.
|
||||
*/
|
||||
type Doc<T extends { id: string }> = T & { _id?: Types.ObjectId };
|
||||
|
||||
export type ProjectDoc = Doc<ProjectReadModel> & { memberIds: string[] };
|
||||
/** Links are split: outward ones come from the issue itself, inward ones from other issues' events. */
|
||||
@@ -34,11 +40,11 @@ export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string };
|
||||
export type AutomationDoc = Omit<Automation, 'id'> & { _id: string };
|
||||
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
|
||||
export type AutomationDoc = Doc<Automation>;
|
||||
export interface AutomationRunDoc {
|
||||
/** `ruleId:eventKey`: a rule runs once per event. */
|
||||
_id: string;
|
||||
id: string;
|
||||
at: Date;
|
||||
}
|
||||
export interface GitRepositoryDoc {
|
||||
@@ -52,13 +58,15 @@ export interface GitRepositoryDoc {
|
||||
* kept: it reads as a repository named "principal" until the list is saved.
|
||||
*/
|
||||
export interface GitIntegrationDoc {
|
||||
_id: string;
|
||||
/** The project's id. */
|
||||
id: string;
|
||||
repositories?: GitRepositoryDoc[];
|
||||
secret?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
export interface NotificationSettingsDoc {
|
||||
_id: string;
|
||||
/** The user's id. */
|
||||
id: string;
|
||||
email: boolean;
|
||||
}
|
||||
|
||||
@@ -79,63 +87,105 @@ export const ReadModels = {
|
||||
AutomationRun: 'AutomationRun',
|
||||
} as const;
|
||||
|
||||
const userRef = { _id: false, id: String, name: String };
|
||||
// `type` must be wrapped: a bare `type` key would declare the whole subdocument a String.
|
||||
const link = {
|
||||
_id: false,
|
||||
type: { type: String },
|
||||
direction: String,
|
||||
issueId: String,
|
||||
issueKey: String,
|
||||
};
|
||||
const loose = { strict: false, versionKey: false, _id: false } as const;
|
||||
/*
|
||||
* Every collection keeps the whole read model (`strict: false`); the props
|
||||
* declared here are the ones with an index or a shape worth stating. `id` is
|
||||
* the domain id (unique, what every query goes by); `_id` is Mongo's own.
|
||||
*/
|
||||
const LOOSE = { strict: false, versionKey: false } as const;
|
||||
|
||||
export const ProjectSchema = new Schema<ProjectDoc>(
|
||||
{
|
||||
_id: String,
|
||||
key: { type: String, unique: true },
|
||||
isPublic: { type: Boolean, index: true },
|
||||
memberIds: { type: [String], index: true },
|
||||
owner: userRef,
|
||||
members: [{ _id: false, user: userRef, role: String }],
|
||||
version: Number,
|
||||
},
|
||||
{ ...loose, collection: 'projects_view' },
|
||||
);
|
||||
@Schema({ _id: false })
|
||||
class UserRefProp {
|
||||
@Prop({ type: String }) id!: string;
|
||||
@Prop({ type: String }) name!: string;
|
||||
}
|
||||
const UserRefSchema = SchemaFactory.createForClass(UserRefProp);
|
||||
|
||||
export const IssueSchema = new Schema<IssueDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: { type: String, index: true },
|
||||
key: { type: String, unique: true },
|
||||
status: String,
|
||||
statusCategory: String,
|
||||
reporter: userRef,
|
||||
assignee: userRef,
|
||||
release: { _id: false, id: String, name: String },
|
||||
sprint: { _id: false, id: String, name: String },
|
||||
outwardLinks: [link],
|
||||
inwardLinks: [link],
|
||||
watchers: [userRef],
|
||||
parent: { _id: false, id: String, key: String, title: String },
|
||||
checklist: [{ _id: false, id: String, text: String, done: Boolean }],
|
||||
attachments: [
|
||||
{
|
||||
_id: false,
|
||||
id: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
size: Number,
|
||||
uploadedBy: userRef,
|
||||
uploadedAt: String,
|
||||
commentId: String,
|
||||
},
|
||||
],
|
||||
labels: [String],
|
||||
version: Number,
|
||||
},
|
||||
{ ...loose, collection: 'issues_view' },
|
||||
);
|
||||
@Schema({ _id: false })
|
||||
class MemberProp {
|
||||
@Prop({ type: UserRefSchema }) user!: UserRefProp;
|
||||
@Prop({ type: String }) role!: string;
|
||||
}
|
||||
|
||||
@Schema({ _id: false })
|
||||
class LinkProp {
|
||||
@Prop({ type: String }) type!: string;
|
||||
@Prop({ type: String }) direction!: string;
|
||||
@Prop({ type: String }) issueId!: string;
|
||||
@Prop({ type: String }) issueKey!: string;
|
||||
}
|
||||
const LinkSchema = SchemaFactory.createForClass(LinkProp);
|
||||
|
||||
@Schema({ _id: false })
|
||||
class NamedRefProp {
|
||||
@Prop({ type: String }) id!: string;
|
||||
@Prop({ type: String }) name!: string;
|
||||
}
|
||||
|
||||
@Schema({ _id: false })
|
||||
class ParentProp {
|
||||
@Prop({ type: String }) id!: string;
|
||||
@Prop({ type: String }) key!: string;
|
||||
@Prop({ type: String }) title!: string;
|
||||
}
|
||||
|
||||
@Schema({ _id: false })
|
||||
class ChecklistItemProp {
|
||||
@Prop({ type: String }) id!: string;
|
||||
@Prop({ type: String }) text!: string;
|
||||
@Prop({ type: Boolean }) done!: boolean;
|
||||
}
|
||||
|
||||
@Schema({ _id: false })
|
||||
class AttachmentProp {
|
||||
@Prop({ type: String }) id!: string;
|
||||
@Prop({ type: String }) fileName!: string;
|
||||
@Prop({ type: String }) mimeType!: string;
|
||||
@Prop({ type: Number }) size!: number;
|
||||
@Prop({ type: UserRefSchema }) uploadedBy!: UserRefProp;
|
||||
@Prop({ type: String }) uploadedAt!: string;
|
||||
@Prop({ type: String }) commentId!: string;
|
||||
}
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'projects_view' })
|
||||
class ProjectDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, unique: true }) key!: string;
|
||||
@Prop({ type: Boolean, index: true }) isPublic!: boolean;
|
||||
@Prop({ type: [String], index: true }) memberIds!: string[];
|
||||
@Prop({ type: UserRefSchema }) owner!: UserRefProp;
|
||||
@Prop({ type: [SchemaFactory.createForClass(MemberProp)] })
|
||||
members!: MemberProp[];
|
||||
@Prop({ type: Number }) version!: number;
|
||||
}
|
||||
export const ProjectSchema = SchemaFactory.createForClass(ProjectDocument);
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'issues_view' })
|
||||
class IssueDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, index: true }) projectId!: string;
|
||||
@Prop({ type: String, unique: true }) key!: string;
|
||||
@Prop({ type: String }) status!: string;
|
||||
@Prop({ type: String }) statusCategory!: string;
|
||||
@Prop({ type: UserRefSchema }) reporter!: UserRefProp;
|
||||
@Prop({ type: UserRefSchema }) assignee!: UserRefProp | null;
|
||||
@Prop({ type: SchemaFactory.createForClass(NamedRefProp) })
|
||||
release!: NamedRefProp | null;
|
||||
@Prop({ type: SchemaFactory.createForClass(NamedRefProp) })
|
||||
sprint!: NamedRefProp | null;
|
||||
@Prop({ type: [LinkSchema] }) outwardLinks!: LinkProp[];
|
||||
@Prop({ type: [LinkSchema] }) inwardLinks!: LinkProp[];
|
||||
@Prop({ type: [UserRefSchema] }) watchers!: UserRefProp[];
|
||||
@Prop({ type: SchemaFactory.createForClass(ParentProp) })
|
||||
parent!: ParentProp | null;
|
||||
@Prop({ type: [SchemaFactory.createForClass(ChecklistItemProp)] })
|
||||
checklist!: ChecklistItemProp[];
|
||||
@Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] })
|
||||
attachments!: AttachmentProp[];
|
||||
@Prop({ type: [String] }) labels!: string[];
|
||||
@Prop({ type: Number }) version!: number;
|
||||
}
|
||||
export const IssueSchema = SchemaFactory.createForClass(IssueDocument);
|
||||
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
|
||||
IssueSchema.index({ projectId: 1, statusCategory: 1 });
|
||||
IssueSchema.index({ 'assignee.id': 1 });
|
||||
@@ -147,115 +197,137 @@ IssueSchema.index({ 'sprint.id': 1 });
|
||||
IssueSchema.index({ trashedAt: 1 }, { sparse: true });
|
||||
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||
|
||||
export const CommentSchema = new Schema<CommentDoc>(
|
||||
{
|
||||
_id: String,
|
||||
issueId: { type: String, index: true },
|
||||
replyTo: { type: String, index: true },
|
||||
author: userRef,
|
||||
version: Number,
|
||||
},
|
||||
{ ...loose, collection: 'comments_view' },
|
||||
);
|
||||
@Schema({ ...LOOSE, collection: 'comments_view' })
|
||||
class CommentDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, index: true }) issueId!: string;
|
||||
@Prop({ type: String, index: true }) replyTo!: string | null;
|
||||
@Prop({ type: UserRefSchema }) author!: UserRefProp;
|
||||
@Prop({ type: Number }) version!: number;
|
||||
}
|
||||
export const CommentSchema = SchemaFactory.createForClass(CommentDocument);
|
||||
CommentSchema.index({ issueId: 1, createdAt: 1 });
|
||||
|
||||
export const ReleaseSchema = new Schema<ReleaseDoc>(
|
||||
{ _id: String, projectId: { type: String, index: true }, version: Number },
|
||||
{ ...loose, collection: 'releases_view' },
|
||||
);
|
||||
@Schema({ ...LOOSE, collection: 'releases_view' })
|
||||
class ReleaseDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, index: true }) projectId!: string;
|
||||
@Prop({ type: Number }) version!: number;
|
||||
}
|
||||
export const ReleaseSchema = SchemaFactory.createForClass(ReleaseDocument);
|
||||
|
||||
export const SprintSchema = new Schema<SprintDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: { type: String, index: true },
|
||||
status: String,
|
||||
version: Number,
|
||||
},
|
||||
{ ...loose, collection: 'sprints_view' },
|
||||
);
|
||||
@Schema({ ...LOOSE, collection: 'sprints_view' })
|
||||
class SprintDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, index: true }) projectId!: string;
|
||||
@Prop({ type: String }) status!: string;
|
||||
@Prop({ type: Number }) version!: number;
|
||||
}
|
||||
export const SprintSchema = SchemaFactory.createForClass(SprintDocument);
|
||||
|
||||
export const ActivitySchema = new Schema<ActivityDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: String,
|
||||
issueId: String,
|
||||
actor: userRef,
|
||||
},
|
||||
{ ...loose, collection: 'activity' },
|
||||
);
|
||||
@Schema({ ...LOOSE, collection: 'activity' })
|
||||
class ActivityDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String }) projectId!: string;
|
||||
@Prop({ type: String }) issueId!: string | null;
|
||||
@Prop({ type: UserRefSchema }) actor!: UserRefProp;
|
||||
}
|
||||
export const ActivitySchema = SchemaFactory.createForClass(ActivityDocument);
|
||||
ActivitySchema.index({ projectId: 1, occurredAt: -1 });
|
||||
ActivitySchema.index({ issueId: 1, occurredAt: -1 });
|
||||
|
||||
/** Every status and release step of every ticket (statistics). */
|
||||
export const TimelineSchema = new Schema<TimelineDoc>(
|
||||
{ _id: String, projectId: String, issueId: String, at: String },
|
||||
{ ...loose, collection: 'issue_timeline' },
|
||||
);
|
||||
@Schema({ ...LOOSE, collection: 'issue_timeline' })
|
||||
class TimelineDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String }) projectId!: string;
|
||||
@Prop({ type: String }) issueId!: string;
|
||||
@Prop({ type: String }) at!: string;
|
||||
}
|
||||
export const TimelineSchema = SchemaFactory.createForClass(TimelineDocument);
|
||||
TimelineSchema.index({ projectId: 1, at: 1 });
|
||||
|
||||
export const NotificationSchema = new Schema<NotificationDoc>(
|
||||
{
|
||||
_id: String,
|
||||
userId: String,
|
||||
readAt: String,
|
||||
createdAt: String,
|
||||
actor: userRef,
|
||||
},
|
||||
{ ...loose, collection: 'notifications' },
|
||||
);
|
||||
@Schema({ ...LOOSE, collection: 'notifications' })
|
||||
class NotificationDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String }) userId!: string;
|
||||
@Prop({ type: String }) readAt!: string | null;
|
||||
@Prop({ type: String }) createdAt!: string;
|
||||
@Prop({ type: UserRefSchema }) actor!: UserRefProp;
|
||||
}
|
||||
export const NotificationSchema =
|
||||
SchemaFactory.createForClass(NotificationDocument);
|
||||
NotificationSchema.index({ userId: 1, createdAt: -1 });
|
||||
NotificationSchema.index({ userId: 1, readAt: 1 });
|
||||
|
||||
export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
|
||||
{ _id: String, email: Boolean },
|
||||
{ ...loose, collection: 'notification_settings' },
|
||||
@Schema({ ...LOOSE, collection: 'notification_settings' })
|
||||
class NotificationSettingsDocument {
|
||||
/** The user's id. */
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: Boolean }) email!: boolean;
|
||||
}
|
||||
export const NotificationSettingsSchema = SchemaFactory.createForClass(
|
||||
NotificationSettingsDocument,
|
||||
);
|
||||
|
||||
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: String,
|
||||
owner: userRef,
|
||||
name: String,
|
||||
shared: Boolean,
|
||||
createdAt: String,
|
||||
},
|
||||
{ ...loose, collection: 'saved_filters' },
|
||||
);
|
||||
@Schema({ ...LOOSE, collection: 'saved_filters' })
|
||||
class SavedFilterDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String }) projectId!: string;
|
||||
@Prop({ type: UserRefSchema }) owner!: UserRefProp;
|
||||
@Prop({ type: String }) name!: string;
|
||||
@Prop({ type: Boolean }) shared!: boolean;
|
||||
@Prop({ type: String }) createdAt!: string;
|
||||
}
|
||||
export const SavedFilterSchema =
|
||||
SchemaFactory.createForClass(SavedFilterDocument);
|
||||
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
||||
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||
|
||||
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
||||
{ _id: String, repositories: [Object], secret: String, createdAt: String },
|
||||
{ ...loose, collection: 'git_integrations' },
|
||||
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
||||
class GitIntegrationDocument {
|
||||
/** The project's id. */
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: [Object] }) repositories!: GitRepositoryDoc[];
|
||||
@Prop({ type: String }) secret!: string;
|
||||
@Prop({ type: String }) createdAt!: string;
|
||||
}
|
||||
export const GitIntegrationSchema = SchemaFactory.createForClass(
|
||||
GitIntegrationDocument,
|
||||
);
|
||||
|
||||
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: { type: String, index: true },
|
||||
active: Boolean,
|
||||
events: [String],
|
||||
createdBy: userRef,
|
||||
createdAt: String,
|
||||
},
|
||||
{ ...loose, collection: 'outgoing_webhooks' },
|
||||
@Schema({ ...LOOSE, collection: 'outgoing_webhooks' })
|
||||
class OutgoingWebhookDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, index: true }) projectId!: string;
|
||||
@Prop({ type: Boolean }) active!: boolean;
|
||||
@Prop({ type: [String] }) events!: string[];
|
||||
@Prop({ type: UserRefSchema }) createdBy!: UserRefProp;
|
||||
@Prop({ type: String }) createdAt!: string;
|
||||
}
|
||||
export const OutgoingWebhookSchema = SchemaFactory.createForClass(
|
||||
OutgoingWebhookDocument,
|
||||
);
|
||||
|
||||
export const AutomationSchema = new Schema<AutomationDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: { type: String, index: true },
|
||||
active: Boolean,
|
||||
createdBy: userRef,
|
||||
createdAt: String,
|
||||
},
|
||||
{ ...loose, collection: 'automations' },
|
||||
);
|
||||
@Schema({ ...LOOSE, collection: 'automations' })
|
||||
class AutomationDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, index: true }) projectId!: string;
|
||||
@Prop({ type: Boolean }) active!: boolean;
|
||||
@Prop({ type: UserRefSchema }) createdBy!: UserRefProp;
|
||||
@Prop({ type: String }) createdAt!: string;
|
||||
}
|
||||
export const AutomationSchema =
|
||||
SchemaFactory.createForClass(AutomationDocument);
|
||||
|
||||
export const AutomationRunSchema = new Schema<AutomationRunDoc>(
|
||||
{ _id: String, at: Date },
|
||||
{ ...loose, collection: 'automation_runs' },
|
||||
@Schema({ ...LOOSE, collection: 'automation_runs' })
|
||||
class AutomationRunDocument {
|
||||
/** `ruleId:eventKey`: a rule runs once per event. */
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: Date }) at!: Date;
|
||||
}
|
||||
export const AutomationRunSchema = SchemaFactory.createForClass(
|
||||
AutomationRunDocument,
|
||||
);
|
||||
// Redeliveries happen within minutes; a month of memory is plenty.
|
||||
AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 });
|
||||
@@ -277,17 +349,17 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||
];
|
||||
|
||||
export function fromDoc<T extends { _id: string }>(
|
||||
doc: T,
|
||||
): Omit<T, '_id'> & { id: string } {
|
||||
const { _id, ...rest } = doc;
|
||||
return { id: _id, ...rest };
|
||||
/** A document as the model it holds: Mongo's own `_id` left out. */
|
||||
export function fromDoc<T extends { id: string }>(
|
||||
doc: T & { _id?: unknown },
|
||||
): T {
|
||||
const { _id: _ignored, ...rest } = doc;
|
||||
return rest as T;
|
||||
}
|
||||
|
||||
export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
const { _id, outwardLinks, inwardLinks, ...rest } = doc;
|
||||
const { _id: _ignored, outwardLinks, inwardLinks, ...rest } = doc;
|
||||
return {
|
||||
id: _id,
|
||||
...rest,
|
||||
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
|
||||
// Tickets projected before attachments existed have none.
|
||||
|
||||
@@ -26,7 +26,7 @@ export class MongoSavedFiltersRepository extends SavedFiltersRepository {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SavedFilterReadModel | null> {
|
||||
const doc = await this.filters.findById(id).lean<SavedFilterDoc>();
|
||||
const doc = await this.filters.findOne({ id: id }).lean<SavedFilterDoc>();
|
||||
return doc ? toFilter(doc) : null;
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ export class MongoSavedFiltersRepository extends SavedFiltersRepository {
|
||||
}
|
||||
|
||||
async insert({ id, ...rest }: SavedFilterReadModel): Promise<void> {
|
||||
await this.filters.create({ _id: id, ...rest });
|
||||
await this.filters.create({ id, ...rest });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.filters.deleteOne({ _id: id });
|
||||
await this.filters.deleteOne({ id });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class MongoFindSprintsRepository extends FindSprintsRepository {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SprintReadModel | null> {
|
||||
const doc = await this.sprints.findById(id).lean<SprintDoc>();
|
||||
const doc = await this.sprints.findOne({ id: id }).lean<SprintDoc>();
|
||||
return doc ? fromDoc(doc) : null;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,6 @@ export class MongoUpsertMaterializedSprintRepository extends UpsertMaterializedS
|
||||
}
|
||||
|
||||
async remove(sprintId: string) {
|
||||
await this.sprints.deleteOne({ _id: sprintId });
|
||||
await this.sprints.deleteOne({ id: sprintId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ export class StatusCategoryBackfill implements OnApplicationBootstrap {
|
||||
|
||||
const projectIds = [...new Set(groups.map((g) => g._id.projectId))];
|
||||
const projects = await this.projects
|
||||
.find({ _id: { $in: projectIds } }, { settings: 1 })
|
||||
.lean<Pick<ProjectDoc, '_id' | 'settings'>[]>();
|
||||
.find({ id: { $in: projectIds } }, { id: 1, settings: 1 })
|
||||
.lean<Pick<ProjectDoc, 'id' | 'settings'>[]>();
|
||||
const workflows = new Map(
|
||||
projects.map((p) => [
|
||||
p._id,
|
||||
p.id,
|
||||
p.settings
|
||||
? new ProjectWorkflow(p.settings)
|
||||
: ProjectWorkflow.default(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Model } from 'mongoose';
|
||||
* Idempotent upsert: only applies a change carrying a newer aggregate
|
||||
* version (event position). Redelivered events become no-ops.
|
||||
*/
|
||||
export async function upsertIfNewer<T extends { _id: string }>(
|
||||
export async function upsertIfNewer<T extends { id: string }>(
|
||||
model: Model<T>,
|
||||
id: string,
|
||||
version: number,
|
||||
@@ -13,13 +13,13 @@ export async function upsertIfNewer<T extends { _id: string }>(
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await model.updateOne(
|
||||
{ _id: id, version: { $lt: version } } as never,
|
||||
{ id, version: { $lt: version } } as never,
|
||||
{ $set: { ...set, version }, $setOnInsert: setOnInsert } as never,
|
||||
{ upsert: true },
|
||||
);
|
||||
return result.modifiedCount + result.upsertedCount > 0;
|
||||
} catch (error) {
|
||||
// Duplicate _id on upsert: the stored document is already newer.
|
||||
// Duplicate id on upsert: the stored document is already newer.
|
||||
if ((error as { code?: number }).code === 11000) return false;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
|
||||
async remove(projectId: string) {
|
||||
await Promise.all([
|
||||
this.projects.deleteOne({ _id: projectId }),
|
||||
this.projects.deleteOne({ id: projectId }),
|
||||
this.activity.deleteMany({ projectId }),
|
||||
this.timeline.deleteMany({ projectId }),
|
||||
// Views of the board of a project that is gone.
|
||||
this.savedFilters.deleteMany({ projectId }),
|
||||
this.gitIntegrations.deleteOne({ _id: projectId }),
|
||||
this.gitIntegrations.deleteOne({ id: projectId }),
|
||||
this.outgoingWebhooks.deleteMany({ projectId }),
|
||||
this.automations.deleteMany({ projectId }),
|
||||
]);
|
||||
@@ -102,7 +102,7 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
|
||||
async remove(issueId: string) {
|
||||
await Promise.all([
|
||||
this.issues.deleteOne({ _id: issueId }),
|
||||
this.issues.deleteOne({ id: issueId }),
|
||||
this.issues.updateMany(
|
||||
{
|
||||
$or: [
|
||||
@@ -138,21 +138,21 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
|
||||
async addWatcher(issueId: string, user: UserRefReadModel) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId, 'watchers.id': { $ne: user.id } },
|
||||
{ id: issueId, 'watchers.id': { $ne: user.id } },
|
||||
{ $push: { watchers: user } },
|
||||
);
|
||||
}
|
||||
|
||||
async removeWatcher(issueId: string, userId: string) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ id: issueId },
|
||||
{ $pull: { watchers: { id: userId } } },
|
||||
);
|
||||
}
|
||||
|
||||
async addChecklistItem(issueId: string, item: ChecklistItemReadModel) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId, 'checklist.id': { $ne: item.id } },
|
||||
{ id: issueId, 'checklist.id': { $ne: item.id } },
|
||||
{ $push: { checklist: item } },
|
||||
);
|
||||
}
|
||||
@@ -170,14 +170,14 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
);
|
||||
if (Object.keys(set).length === 0) return;
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId, 'checklist.id': itemId },
|
||||
{ id: issueId, 'checklist.id': itemId },
|
||||
{ $set: set },
|
||||
);
|
||||
}
|
||||
|
||||
async removeChecklistItem(issueId: string, itemId: string) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ id: issueId },
|
||||
{ $pull: { checklist: { id: itemId } } },
|
||||
);
|
||||
}
|
||||
@@ -185,11 +185,11 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
async putDevLink(issueId: string, link: DevLink) {
|
||||
// Same URL: the newer report replaces the older one.
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ id: issueId },
|
||||
{ $pull: { devLinks: { url: link.url } } },
|
||||
);
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ id: issueId },
|
||||
{ $push: { devLinks: { $each: [link], $slice: -MAX_DEV_LINKS } } },
|
||||
);
|
||||
}
|
||||
@@ -203,14 +203,14 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
|
||||
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
|
||||
{ id: issueId, 'attachments.id': { $ne: attachment.id } },
|
||||
{ $push: { attachments: attachment } },
|
||||
);
|
||||
}
|
||||
|
||||
async removeAttachment(issueId: string, attachmentId: string) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ id: issueId },
|
||||
{ $pull: { attachments: { id: attachmentId } } },
|
||||
);
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
|
||||
await this.issues.updateOne(
|
||||
{
|
||||
_id: issueId,
|
||||
id: issueId,
|
||||
[field]: {
|
||||
$not: { $elemMatch: { type: link.type, issueId: link.issueId } },
|
||||
},
|
||||
@@ -234,13 +234,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
) {
|
||||
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ id: issueId },
|
||||
{ $pull: { [field]: { type: link.type, issueId: link.issueId } } },
|
||||
);
|
||||
}
|
||||
|
||||
async setCommentCount(issueId: string, commentCount: number) {
|
||||
await this.issues.updateOne({ _id: issueId }, { $set: { commentCount } });
|
||||
await this.issues.updateOne({ id: issueId }, { $set: { commentCount } });
|
||||
}
|
||||
|
||||
async recategorize(
|
||||
@@ -289,7 +289,7 @@ export class MongoUpsertMaterializedCommentRepository extends UpsertMaterialized
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.comments.deleteOne({ _id: id });
|
||||
await this.comments.deleteOne({ id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ export class MongoUpsertMaterializedReleaseRepository extends UpsertMaterialized
|
||||
}
|
||||
|
||||
async remove(releaseId: string) {
|
||||
await this.releases.deleteOne({ _id: releaseId });
|
||||
await this.releases.deleteOne({ id: releaseId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ export class MongoUpsertMaterializedActivityRepository extends UpsertMaterialize
|
||||
|
||||
async insert({ id, ...fields }: ActivityReadModel): Promise<boolean> {
|
||||
const result = await this.activity.updateOne(
|
||||
{ _id: id },
|
||||
{ id },
|
||||
{ $setOnInsert: fields },
|
||||
{ upsert: true },
|
||||
);
|
||||
@@ -345,7 +345,7 @@ export class MongoUpsertMaterializedTimelineRepository extends UpsertMaterialize
|
||||
|
||||
async record({ id, ...fields }: IssueTimelineEntry): Promise<void> {
|
||||
await this.timeline.updateOne(
|
||||
{ _id: id },
|
||||
{ id },
|
||||
{ $setOnInsert: fields },
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||
|
||||
/*
|
||||
* Write-side lookup tables. The event store holds the truth; these only
|
||||
* enforce uniqueness and sequences that cannot be checked per aggregate.
|
||||
* Each row has a uuid `id`; what makes it unique is a constraint apart.
|
||||
*/
|
||||
|
||||
@Entity('project_keys')
|
||||
export class ProjectKeyEntity {
|
||||
@PrimaryColumn({ length: 10 })
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Index('uq_project_keys_key', { unique: true })
|
||||
@Column({ length: 10 })
|
||||
key!: string;
|
||||
|
||||
@Column({ name: 'project_id', length: 64 })
|
||||
@@ -16,7 +21,12 @@ export class ProjectKeyEntity {
|
||||
|
||||
@Entity('issue_sequences')
|
||||
export class IssueSequenceEntity {
|
||||
@PrimaryColumn({ name: 'project_id', length: 64 })
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
/** `ON CONFLICT (project_id)` counts on it. */
|
||||
@Index('uq_issue_sequences_project', { unique: true })
|
||||
@Column({ name: 'project_id', length: 64 })
|
||||
projectId!: string;
|
||||
|
||||
@Column({ name: 'last_number', type: 'int', default: 0 })
|
||||
@@ -24,11 +34,15 @@ export class IssueSequenceEntity {
|
||||
}
|
||||
|
||||
@Entity('release_names')
|
||||
@Unique('uq_release_names_project_name', ['projectId', 'name'])
|
||||
export class ReleaseNameEntity {
|
||||
@PrimaryColumn({ name: 'project_id', length: 64 })
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'project_id', length: 64 })
|
||||
projectId!: string;
|
||||
|
||||
@PrimaryColumn({ length: 60 })
|
||||
@Column({ length: 60 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'release_id', length: 64 })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DynamicModule, Module, Type } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
@@ -54,8 +54,6 @@ import {
|
||||
} from './mongo/upsert-materialized.repositories';
|
||||
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
|
||||
import { RankBackfill } from './mongo/rank.backfill';
|
||||
import { ReminderScheduler } from './notifications/reminder.scheduler';
|
||||
import { TrashScheduler } from './notifications/trash.scheduler';
|
||||
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
|
||||
import {
|
||||
PostgresIssueNumbersRepository,
|
||||
@@ -158,13 +156,16 @@ const PORTS = [
|
||||
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
|
||||
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
|
||||
],
|
||||
providers: [
|
||||
...PORTS,
|
||||
StatusCategoryBackfill,
|
||||
RankBackfill,
|
||||
ReminderScheduler,
|
||||
TrashScheduler,
|
||||
],
|
||||
providers: [...PORTS, StatusCategoryBackfill, RankBackfill],
|
||||
exports: PORTS.map((port) => port.provide),
|
||||
})
|
||||
export class TrackerInfrastructureModule {}
|
||||
export class TrackerInfrastructureModule {
|
||||
/**
|
||||
* The user directory asks the identity context through its service, so
|
||||
* the tracker needs that context's application module: the composition
|
||||
* root passes the same one the identity context is built with.
|
||||
*/
|
||||
static withIdentity(identity: Type | DynamicModule): DynamicModule {
|
||||
return { module: TrackerInfrastructureModule, imports: [identity] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { SendDueRemindersCommand } from '@task-stream/api/tracker/application';
|
||||
import { NotificationsService } from '@task-stream/api/tracker/application';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
@@ -34,7 +33,7 @@ export class ReminderScheduler
|
||||
private timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -56,8 +55,8 @@ export class ReminderScheduler
|
||||
const timeZone =
|
||||
this.config.get<string>('REMINDERS_TIMEZONE') || 'Europe/Madrid';
|
||||
try {
|
||||
const sent: number = await this.commandBus.execute(
|
||||
new SendDueRemindersCommand(dayIn(new Date(), timeZone)),
|
||||
const sent = await this.notifications.sendDueReminders(
|
||||
dayIn(new Date(), timeZone),
|
||||
);
|
||||
if (sent) this.logger.log(`Due-date reminders checked (${sent})`);
|
||||
} catch (error) {
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
EmptyTrashCommand,
|
||||
IssuesService,
|
||||
TRASH_DAYS,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
|
||||
@@ -25,7 +24,7 @@ export class TrashScheduler
|
||||
private timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly issues: IssuesService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -46,9 +45,7 @@ export class TrashScheduler
|
||||
Number(this.config.get('TRASH_DAYS') ?? TRASH_DAYS) || TRASH_DAYS;
|
||||
const before = new Date(Date.now() - days * 24 * HOUR).toISOString();
|
||||
try {
|
||||
const deleted: number = await this.commandBus.execute(
|
||||
new EmptyTrashCommand(before),
|
||||
);
|
||||
const deleted = await this.issues.emptyTrash(before);
|
||||
if (deleted)
|
||||
this.logger.log(`Deleted ${deleted} ticket(s) from the trash`);
|
||||
} catch (error) {
|
||||
@@ -13,6 +13,8 @@ import { CommentsController } from './http/comments.controller';
|
||||
import { IssuesController } from './http/issues.controller';
|
||||
import { ExploreController } from './http/explore.controller';
|
||||
import { ProposalLimiter } from './http/proposal-limiter';
|
||||
import { ReminderScheduler } from './schedulers/reminder.scheduler';
|
||||
import { TrashScheduler } from './schedulers/trash.scheduler';
|
||||
import { ProjectsController } from './http/projects.controller';
|
||||
import { Refs } from './http/refs';
|
||||
import { ReleasesController } from './http/releases.controller';
|
||||
@@ -65,6 +67,9 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
providers: [
|
||||
Refs,
|
||||
ProposalLimiter,
|
||||
// Timers: entry points like the HTTP and GraphQL ones.
|
||||
ReminderScheduler,
|
||||
TrashScheduler,
|
||||
ProjectResolver,
|
||||
IssueResolver,
|
||||
CommentResolver,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IssueLink,
|
||||
IssuesGateway,
|
||||
IssueLinkType,
|
||||
IssueSummary,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
@@ -120,6 +121,20 @@ export class IssueFacade {
|
||||
}
|
||||
|
||||
/** Places the open ticket under another one by its key; null: back to the top. */
|
||||
/**
|
||||
* Tickets to link to or hang the open one from: the most recently updated
|
||||
* of all my projects (links may cross projects), the open one aside.
|
||||
*/
|
||||
candidates(): Observable<IssueSummary[]> {
|
||||
return this.issues
|
||||
.mine({ assignedToMe: false, limit: 200 })
|
||||
.pipe(
|
||||
map((page) =>
|
||||
page.items.filter((item) => item.id !== this.store.issue()?.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setParent(parentKey: string | null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.parentRequested({ issueId, parentKey }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiCheckboxComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
@@ -34,6 +35,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
selector: 'ts-backlog-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
BulkBarComponent,
|
||||
RouterLink,
|
||||
CdkDrag,
|
||||
@@ -96,14 +98,15 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
[cdkDragData]="issue"
|
||||
[class.is-selected]="selected().has(issue.id)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<pi-checkbox
|
||||
class="ts-backlog__pick"
|
||||
[attr.aria-label]="'Seleccionar ' + issue.key"
|
||||
[disabled]="!project.canContribute()"
|
||||
[checked]="selected().has(issue.id)"
|
||||
(change)="toggle(issue.id, $any($event.target).checked)"
|
||||
/>
|
||||
(checkedChange)="toggle(issue.id, $event)"
|
||||
><span class="visually-hidden"
|
||||
>Seleccionar {{ issue.key }}</span
|
||||
></pi-checkbox
|
||||
>
|
||||
<span class="ts-backlog__pos ts-mono">{{ i + 1 }}</span>
|
||||
<span
|
||||
class="ts-backlog__type"
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { catchError, distinctUntilChanged, filter, of, switchMap } from 'rxjs';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSearchableSelectFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
@@ -93,6 +96,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSearchableSelectFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
@@ -404,8 +408,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(submit)="$event.preventDefault(); setParent()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field
|
||||
placeholder="Colgar de… (clave de la épica, p. ej. WEB-3)"
|
||||
<pi-searchable-select-field
|
||||
placeholder="Colgar de… (la épica o el ticket padre)"
|
||||
searchPlaceholder="Busca por clave o título"
|
||||
emptyLabel="Ningún ticket de este proyecto coincide"
|
||||
[options]="parentOptions()"
|
||||
[(value)]="parentKey"
|
||||
/>
|
||||
<pi-action-button
|
||||
@@ -413,7 +420,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Colgar"
|
||||
[disabled]="!parentKey().trim()"
|
||||
[disabled]="!parentKey()"
|
||||
(execute)="setParent()"
|
||||
/>
|
||||
</form>
|
||||
@@ -512,15 +519,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
[options]="linkTypeOptions"
|
||||
[(value)]="linkType"
|
||||
/>
|
||||
<pi-text-field
|
||||
placeholder="Clave, p. ej. BE-3"
|
||||
<pi-searchable-select-field
|
||||
placeholder="Elige el ticket"
|
||||
searchPlaceholder="Busca por clave o título"
|
||||
emptyLabel="Ningún ticket coincide"
|
||||
[options]="linkOptions()"
|
||||
[(value)]="linkTarget"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Relacionar"
|
||||
[disabled]="!linkTarget().trim()"
|
||||
[disabled]="!linkTarget()"
|
||||
(execute)="link()"
|
||||
/>
|
||||
</form>
|
||||
@@ -1066,7 +1076,33 @@ export class IssuePage {
|
||||
|
||||
// Inline forms: plain signals (a required validator would flag them red right after submitting).
|
||||
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
|
||||
protected readonly linkTarget = signal('');
|
||||
protected readonly linkTarget = signal<string | null>(null);
|
||||
/** Tickets of my projects, to pick from instead of typing a key. */
|
||||
private readonly candidates = toSignal(
|
||||
toObservable(computed(() => this.facade.issue()?.id ?? null)).pipe(
|
||||
filter((id): id is string => !!id),
|
||||
distinctUntilChanged(),
|
||||
switchMap(() => this.facade.candidates().pipe(catchError(() => of([])))),
|
||||
),
|
||||
{ initialValue: [] },
|
||||
);
|
||||
/** Any of them: links may cross projects (FE ↔ BE ↔ QA). */
|
||||
protected readonly linkOptions = computed(() =>
|
||||
this.candidates().map((issue) => ({
|
||||
value: issue.key,
|
||||
label: `${issue.key} · ${issue.title}`,
|
||||
})),
|
||||
);
|
||||
/** Only this project's: a ticket hangs from one of its own. */
|
||||
protected readonly parentOptions = computed(() => {
|
||||
const projectId = this.facade.issue()?.projectId;
|
||||
return this.candidates()
|
||||
.filter((issue) => issue.projectId === projectId)
|
||||
.map((issue) => ({
|
||||
value: issue.key,
|
||||
label: `${issue.key} · ${issue.title}`,
|
||||
}));
|
||||
});
|
||||
protected readonly commentBody = signal('');
|
||||
/** The comment being answered, and the answer being written. */
|
||||
protected readonly replyingTo = signal<string | null>(null);
|
||||
@@ -1133,7 +1169,7 @@ export class IssuePage {
|
||||
protected readonly checklistText = signal('');
|
||||
protected readonly subtaskTitle = signal('');
|
||||
protected readonly subtaskType = signal<string | null>(null);
|
||||
protected readonly parentKey = signal('');
|
||||
protected readonly parentKey = signal<string | null>(null);
|
||||
/** Active types; subtasks start as tasks when the project has them. */
|
||||
protected readonly subtaskTypeOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
@@ -1227,10 +1263,10 @@ export class IssuePage {
|
||||
}
|
||||
|
||||
protected setParent(): void {
|
||||
const key = this.parentKey().trim();
|
||||
const key = this.parentKey();
|
||||
if (!key) return;
|
||||
this.facade.setParent(key);
|
||||
this.parentKey.set('');
|
||||
this.parentKey.set(null);
|
||||
}
|
||||
|
||||
protected move(to: string): void {
|
||||
@@ -1283,13 +1319,13 @@ export class IssuePage {
|
||||
}
|
||||
|
||||
protected link(): void {
|
||||
const targetKey = this.linkTarget().trim();
|
||||
const targetKey = this.linkTarget();
|
||||
if (!targetKey) return;
|
||||
this.facade.link(
|
||||
(this.linkType() ?? IssueLinkType.RELATES_TO) as IssueLinkType,
|
||||
targetKey,
|
||||
);
|
||||
this.linkTarget.set('');
|
||||
this.linkTarget.set(null);
|
||||
}
|
||||
|
||||
protected unlink(link: IssueLink): void {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiCheckboxComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiIconComponent,
|
||||
PiNoteComponent,
|
||||
@@ -78,6 +79,7 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
selector: 'ts-list-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
BlockedComponent,
|
||||
BulkBarComponent,
|
||||
VoteComponent,
|
||||
@@ -165,12 +167,13 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
<tr [class.is-selected]="selected().has(issue.id)">
|
||||
@if (project.canContribute()) {
|
||||
<td class="ts-list__pick">
|
||||
<input
|
||||
type="checkbox"
|
||||
[attr.aria-label]="'Seleccionar ' + issue.key"
|
||||
<pi-checkbox
|
||||
[checked]="selected().has(issue.id)"
|
||||
(change)="toggle(issue.id, $any($event.target).checked)"
|
||||
/>
|
||||
(checkedChange)="toggle(issue.id, $event)"
|
||||
><span class="visually-hidden"
|
||||
>Seleccionar {{ issue.key }}</span
|
||||
></pi-checkbox
|
||||
>
|
||||
</td>
|
||||
}
|
||||
<td class="ts-list__key">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@angular/forms/signals';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiSkeletonComponent,
|
||||
@@ -34,6 +35,7 @@ import { MyWorkComponent } from './my-work';
|
||||
selector: 'ts-projects-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
FormField,
|
||||
GoToComponent,
|
||||
ProjectCardComponent,
|
||||
@@ -123,14 +125,13 @@ import { MyWorkComponent } from './my-work';
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
<label class="ts-public-check">
|
||||
<input type="checkbox" [formField]="f.isPublic" />
|
||||
<span>
|
||||
<strong>Proyecto público</strong>
|
||||
<div class="ts-public-check">
|
||||
<pi-checkbox label="Proyecto público" [formField]="f.isPublic" />
|
||||
<p>
|
||||
Cualquiera, también sin iniciar sesión, podrá verlo (solo lectura).
|
||||
Se puede cambiar después en Configuración.
|
||||
</span>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
@@ -164,22 +165,11 @@ import { MyWorkComponent } from './my-work';
|
||||
}
|
||||
}
|
||||
.ts-public-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-muted);
|
||||
cursor: pointer;
|
||||
input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.125rem;
|
||||
accent-color: var(--ts-brand);
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
color: var(--ts-ink);
|
||||
font-size: 0.875rem;
|
||||
p {
|
||||
/* Under the checkbox's text, past the box. */
|
||||
margin: 0 0 0 2.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-projects {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { PiCheckboxComponent, PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { WebhooksComponent } from '../ui/webhooks';
|
||||
import { TransferComponent } from '../ui/transfer';
|
||||
@@ -57,6 +57,7 @@ function idFrom(name: string): string {
|
||||
selector: 'ts-settings-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
RouterLink,
|
||||
GitIntegrationComponent,
|
||||
WebhooksComponent,
|
||||
@@ -263,18 +264,16 @@ function idFrom(name: string): string {
|
||||
})
|
||||
"
|
||||
/>
|
||||
<label class="ts-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="status.requiresReason"
|
||||
(change)="
|
||||
edit('statuses', i, {
|
||||
requiresReason: $any($event.target).checked,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span class="visually-hidden">Pide motivo</span>
|
||||
</label>
|
||||
<pi-checkbox
|
||||
class="ts-check"
|
||||
[checked]="status.requiresReason"
|
||||
(checkedChange)="
|
||||
edit('statuses', i, { requiresReason: $event })
|
||||
"
|
||||
><span class="visually-hidden"
|
||||
>Pide motivo</span
|
||||
></pi-checkbox
|
||||
>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="archive"
|
||||
[ngTemplateOutletContext]="{
|
||||
@@ -330,18 +329,15 @@ function idFrom(name: string): string {
|
||||
@for (to of activeStatuses(); track to.id) {
|
||||
<td>
|
||||
@if (from.id !== to.id) {
|
||||
<input
|
||||
type="checkbox"
|
||||
[attr.aria-label]="from.name + ' → ' + to.name"
|
||||
<pi-checkbox
|
||||
[checked]="allows(from.id, to.id)"
|
||||
(change)="
|
||||
toggleTransition(
|
||||
from.id,
|
||||
to.id,
|
||||
$any($event.target).checked
|
||||
)
|
||||
(checkedChange)="
|
||||
toggleTransition(from.id, to.id, $event)
|
||||
"
|
||||
/>
|
||||
><span class="visually-hidden"
|
||||
>{{ from.name }} → {{ to.name }}</span
|
||||
></pi-checkbox
|
||||
>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
@@ -29,21 +30,26 @@ const NOBODY = '__none__';
|
||||
selector: 'ts-bulk-bar',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
PiActionButtonComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="ts-bulk" role="toolbar" aria-label="Edición en bloque">
|
||||
<label class="ts-bulk__all">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="count() > 0 && !allSelected()"
|
||||
(change)="selectAll.emit($any($event.target).checked)"
|
||||
/>
|
||||
{{ count() ? count() + ' seleccionados' : 'Seleccionar todos' }}
|
||||
</label>
|
||||
<pi-checkbox
|
||||
class="ts-bulk__all"
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="count() > 0 && !allSelected()"
|
||||
[label]="
|
||||
count() === 1
|
||||
? '1 seleccionado'
|
||||
: count()
|
||||
? count() + ' seleccionados'
|
||||
: 'Seleccionar todos'
|
||||
"
|
||||
(checkedChange)="selectAll.emit($event)"
|
||||
/>
|
||||
@if (count()) {
|
||||
<pi-select-field [options]="fields" [(value)]="field" />
|
||||
@switch (field()) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { PiTextFieldComponent } from '@pi/ui';
|
||||
import { PiCheckboxComponent, PiTextFieldComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { Webhook, WebhooksGateway } from '@task-stream/web/tracker/domain';
|
||||
import { timeAgo } from './copy';
|
||||
@@ -38,7 +38,7 @@ const EVENT_LABEL: Record<string, string> = {
|
||||
@Component({
|
||||
selector: 'ts-webhooks',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiTextFieldComponent],
|
||||
imports: [PiCheckboxComponent, PiTextFieldComponent],
|
||||
styleUrl: './settings-card.scss',
|
||||
template: `
|
||||
<section class="ts-panel ts-block ts-hooks" aria-labelledby="hooks-title">
|
||||
@@ -113,24 +113,18 @@ const EVENT_LABEL: Record<string, string> = {
|
||||
<pi-text-field placeholder="https://…" [(value)]="url" />
|
||||
<fieldset class="ts-hooks__picks">
|
||||
<legend>Eventos</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="picked().includes('*')"
|
||||
(change)="pick('*', $any($event.target).checked)"
|
||||
/>
|
||||
Todos
|
||||
</label>
|
||||
<pi-checkbox
|
||||
label="Todos"
|
||||
[checked]="picked().includes('*')"
|
||||
(checkedChange)="pick('*', $event)"
|
||||
/>
|
||||
@for (event of events(); track event) {
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[disabled]="picked().includes('*')"
|
||||
[checked]="picked().includes(event)"
|
||||
(change)="pick(event, $any($event.target).checked)"
|
||||
/>
|
||||
{{ label(event) }}
|
||||
</label>
|
||||
<pi-checkbox
|
||||
[label]="label(event)"
|
||||
[disabled]="picked().includes('*')"
|
||||
[checked]="picked().includes(event)"
|
||||
(checkedChange)="pick(event, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
<div>
|
||||
@@ -214,11 +208,6 @@ const EVENT_LABEL: Record<string, string> = {
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user