feat: uuid ids everywhere, pickers for linked tickets, @pi/ui checkboxes

- Postgres: every table has a uuid `id` (gen_random_uuid()); the event
  store keeps its relay order in `seq`, and the lookup tables their
  uniqueness in constraints of their own.
- MongoDB: `_id` is Mongo's ObjectId; `id` is the domain id (the same
  uuid as the aggregate's stream in Postgres), unique, and every query
  goes by it. Production starts from empty databases.
- Web: linking tickets and hanging one from a parent are picked from a
  searchable select (links across projects, parents within the project)
  instead of typing a key; every checkbox is @pi/ui's pi-checkbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 19:16:43 +02:00
parent 10c8f21ff4
commit 2c501a50d0
30 changed files with 292 additions and 207 deletions

View File

@@ -47,6 +47,8 @@ const tracker = TrackerPresentersModule.withApplication(
url: config.getOrThrow<string>('POSTGRES_URL'), url: config.getOrThrow<string>('POSTGRES_URL'),
autoLoadEntities: true, autoLoadEntities: true,
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'), synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'),
// uuid ids come from gen_random_uuid() (core since PostgreSQL 13).
uuidExtension: 'pgcrypto',
}), }),
}), }),

View File

@@ -103,6 +103,9 @@ sequenceDiagram
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`). segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas - **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`). (`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
- **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una
restricción aparte (la clave, el proyecto, proyecto + nombre). En `events`, el orden global con el que se reenvían los
eventos va en `seq` (`bigserial`), porque un uuid no se ordena; `stream_id` es el id del agregado.
### De escritura a lectura: Redis ### De escritura a lectura: Redis
@@ -122,6 +125,10 @@ sequenceDiagram
que reentregar un evento no tiene efecto. que reentregar un evento no tiene efecto.
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en - Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
`domain/read-models`. `domain/read-models`.
- **Ids**: cada documento tiene `_id` (el `ObjectId` de Mongo, que no sale de la base de datos) e `id` con índice
único, que es el id del dominio: el mismo uuid que el `stream_id` del agregado en Postgres. Todas las consultas van
por `id`. En usuarios es el id de Pireon; en actividad, historial y avisos, uno compuesto que hace idempotente la
proyección.
### Tiempo real ### Tiempo real

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,11 +15,16 @@ import {
SprintReadModel, SprintReadModel,
implicitRank, implicitRank,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { Schema } from 'mongoose'; import { Schema, Types } from 'mongoose';
/* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */ /* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */
type Doc<T extends { id: string }> = Omit<T, 'id'> & { _id: string }; /**
* Each document keeps the domain id (a uuid, the same one the event store
* uses) in `id`, unique; `_id` is Mongo's own ObjectId and means nothing
* outside the database.
*/
type Doc<T extends { id: string }> = T & { _id?: Types.ObjectId };
export type ProjectDoc = Doc<ProjectReadModel> & { memberIds: string[] }; export type ProjectDoc = Doc<ProjectReadModel> & { memberIds: string[] };
/** Links are split: outward ones come from the issue itself, inward ones from other issues' events. */ /** Links are split: outward ones come from the issue itself, inward ones from other issues' events. */
@@ -34,11 +39,11 @@ export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>; export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>; export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>; export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string }; export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
export type AutomationDoc = Omit<Automation, 'id'> & { _id: string }; export type AutomationDoc = Doc<Automation>;
export interface AutomationRunDoc { export interface AutomationRunDoc {
/** `ruleId:eventKey`: a rule runs once per event. */ /** `ruleId:eventKey`: a rule runs once per event. */
_id: string; id: string;
at: Date; at: Date;
} }
export interface GitRepositoryDoc { export interface GitRepositoryDoc {
@@ -52,13 +57,15 @@ export interface GitRepositoryDoc {
* kept: it reads as a repository named "principal" until the list is saved. * kept: it reads as a repository named "principal" until the list is saved.
*/ */
export interface GitIntegrationDoc { export interface GitIntegrationDoc {
_id: string; /** The project's id. */
id: string;
repositories?: GitRepositoryDoc[]; repositories?: GitRepositoryDoc[];
secret?: string; secret?: string;
createdAt?: string; createdAt?: string;
} }
export interface NotificationSettingsDoc { export interface NotificationSettingsDoc {
_id: string; /** The user's id. */
id: string;
email: boolean; email: boolean;
} }
@@ -88,11 +95,13 @@ const link = {
issueId: String, issueId: String,
issueKey: String, issueKey: String,
}; };
const loose = { strict: false, versionKey: false, _id: false } as const; const loose = { strict: false, versionKey: false } as const;
/** The domain id: what every query looks a document up by. */
const domainId = { type: String, required: true, unique: true };
export const ProjectSchema = new Schema<ProjectDoc>( export const ProjectSchema = new Schema<ProjectDoc>(
{ {
_id: String, id: domainId,
key: { type: String, unique: true }, key: { type: String, unique: true },
isPublic: { type: Boolean, index: true }, isPublic: { type: Boolean, index: true },
memberIds: { type: [String], index: true }, memberIds: { type: [String], index: true },
@@ -105,7 +114,7 @@ export const ProjectSchema = new Schema<ProjectDoc>(
export const IssueSchema = new Schema<IssueDoc>( export const IssueSchema = new Schema<IssueDoc>(
{ {
_id: String, id: domainId,
projectId: { type: String, index: true }, projectId: { type: String, index: true },
key: { type: String, unique: true }, key: { type: String, unique: true },
status: String, status: String,
@@ -149,7 +158,7 @@ IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
export const CommentSchema = new Schema<CommentDoc>( export const CommentSchema = new Schema<CommentDoc>(
{ {
_id: String, id: domainId,
issueId: { type: String, index: true }, issueId: { type: String, index: true },
replyTo: { type: String, index: true }, replyTo: { type: String, index: true },
author: userRef, author: userRef,
@@ -160,13 +169,13 @@ export const CommentSchema = new Schema<CommentDoc>(
CommentSchema.index({ issueId: 1, createdAt: 1 }); CommentSchema.index({ issueId: 1, createdAt: 1 });
export const ReleaseSchema = new Schema<ReleaseDoc>( export const ReleaseSchema = new Schema<ReleaseDoc>(
{ _id: String, projectId: { type: String, index: true }, version: Number }, { id: domainId, projectId: { type: String, index: true }, version: Number },
{ ...loose, collection: 'releases_view' }, { ...loose, collection: 'releases_view' },
); );
export const SprintSchema = new Schema<SprintDoc>( export const SprintSchema = new Schema<SprintDoc>(
{ {
_id: String, id: domainId,
projectId: { type: String, index: true }, projectId: { type: String, index: true },
status: String, status: String,
version: Number, version: Number,
@@ -176,7 +185,7 @@ export const SprintSchema = new Schema<SprintDoc>(
export const ActivitySchema = new Schema<ActivityDoc>( export const ActivitySchema = new Schema<ActivityDoc>(
{ {
_id: String, id: domainId,
projectId: String, projectId: String,
issueId: String, issueId: String,
actor: userRef, actor: userRef,
@@ -188,14 +197,14 @@ ActivitySchema.index({ issueId: 1, occurredAt: -1 });
/** Every status and release step of every ticket (statistics). */ /** Every status and release step of every ticket (statistics). */
export const TimelineSchema = new Schema<TimelineDoc>( export const TimelineSchema = new Schema<TimelineDoc>(
{ _id: String, projectId: String, issueId: String, at: String }, { id: domainId, projectId: String, issueId: String, at: String },
{ ...loose, collection: 'issue_timeline' }, { ...loose, collection: 'issue_timeline' },
); );
TimelineSchema.index({ projectId: 1, at: 1 }); TimelineSchema.index({ projectId: 1, at: 1 });
export const NotificationSchema = new Schema<NotificationDoc>( export const NotificationSchema = new Schema<NotificationDoc>(
{ {
_id: String, id: domainId,
userId: String, userId: String,
readAt: String, readAt: String,
createdAt: String, createdAt: String,
@@ -207,13 +216,13 @@ NotificationSchema.index({ userId: 1, createdAt: -1 });
NotificationSchema.index({ userId: 1, readAt: 1 }); NotificationSchema.index({ userId: 1, readAt: 1 });
export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>( export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
{ _id: String, email: Boolean }, { id: domainId, email: Boolean },
{ ...loose, collection: 'notification_settings' }, { ...loose, collection: 'notification_settings' },
); );
export const SavedFilterSchema = new Schema<SavedFilterDoc>( export const SavedFilterSchema = new Schema<SavedFilterDoc>(
{ {
_id: String, id: domainId,
projectId: String, projectId: String,
owner: userRef, owner: userRef,
name: String, name: String,
@@ -226,13 +235,13 @@ SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
SavedFilterSchema.index({ projectId: 1, shared: 1 }); SavedFilterSchema.index({ projectId: 1, shared: 1 });
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>( export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
{ _id: String, repositories: [Object], secret: String, createdAt: String }, { id: domainId, repositories: [Object], secret: String, createdAt: String },
{ ...loose, collection: 'git_integrations' }, { ...loose, collection: 'git_integrations' },
); );
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>( export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
{ {
_id: String, id: domainId,
projectId: { type: String, index: true }, projectId: { type: String, index: true },
active: Boolean, active: Boolean,
events: [String], events: [String],
@@ -244,7 +253,7 @@ export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
export const AutomationSchema = new Schema<AutomationDoc>( export const AutomationSchema = new Schema<AutomationDoc>(
{ {
_id: String, id: domainId,
projectId: { type: String, index: true }, projectId: { type: String, index: true },
active: Boolean, active: Boolean,
createdBy: userRef, createdBy: userRef,
@@ -254,7 +263,7 @@ export const AutomationSchema = new Schema<AutomationDoc>(
); );
export const AutomationRunSchema = new Schema<AutomationRunDoc>( export const AutomationRunSchema = new Schema<AutomationRunDoc>(
{ _id: String, at: Date }, { id: domainId, at: Date },
{ ...loose, collection: 'automation_runs' }, { ...loose, collection: 'automation_runs' },
); );
// Redeliveries happen within minutes; a month of memory is plenty. // Redeliveries happen within minutes; a month of memory is plenty.
@@ -277,17 +286,17 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema }, { name: ReadModels.SavedFilter, schema: SavedFilterSchema },
]; ];
export function fromDoc<T extends { _id: string }>( /** A document as the model it holds: Mongo's own `_id` left out. */
doc: T, export function fromDoc<T extends { id: string }>(
): Omit<T, '_id'> & { id: string } { doc: T & { _id?: unknown },
const { _id, ...rest } = doc; ): T {
return { id: _id, ...rest }; const { _id: _ignored, ...rest } = doc;
return rest as T;
} }
export function issueFromDoc(doc: IssueDoc): IssueReadModel { export function issueFromDoc(doc: IssueDoc): IssueReadModel {
const { _id, outwardLinks, inwardLinks, ...rest } = doc; const { _id: _ignored, outwardLinks, inwardLinks, ...rest } = doc;
return { return {
id: _id,
...rest, ...rest,
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])], links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
// Tickets projected before attachments existed have none. // Tickets projected before attachments existed have none.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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