diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index af7a998..88505fa 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -47,6 +47,8 @@ const tracker = TrackerPresentersModule.withApplication( url: config.getOrThrow('POSTGRES_URL'), autoLoadEntities: true, synchronize: config.get('POSTGRES_SYNCHRONIZE'), + // uuid ids come from gen_random_uuid() (core since PostgreSQL 13). + uuidExtension: 'pgcrypto', }), }), diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md index c8900e2..5987c21 100644 --- a/docs/02-arquitectura.md +++ b/docs/02-arquitectura.md @@ -103,6 +103,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 +125,10 @@ 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`. +- **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 diff --git a/docs/10-futuro.md b/docs/10-futuro.md index b0fe257..3fe2d2e 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -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. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 9672563..f4772b7 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index b12f809..699a479 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/identity/infrastructure/src/lib/mongo/mongo-user.registry.ts b/libs/api/identity/infrastructure/src/lib/mongo/mongo-user.registry.ts index 5629cfe..92c567a 100644 --- a/libs/api/identity/infrastructure/src/lib/mongo/mongo-user.registry.ts +++ b/libs/api/identity/infrastructure/src/lib/mongo/mongo-user.registry.ts @@ -16,20 +16,20 @@ export class MongoUserRegistry extends UserRegistry { async upsert(user: User): Promise { 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 { - 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 { 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, diff --git a/libs/api/identity/infrastructure/src/lib/mongo/user.schema.ts b/libs/api/identity/infrastructure/src/lib/mongo/user.schema.ts index 5851ce9..ba5c652 100644 --- a/libs/api/identity/infrastructure/src/lib/mongo/user.schema.ts +++ b/libs/api/identity/infrastructure/src/lib/mongo/user.schema.ts @@ -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; diff --git a/libs/api/shared/infrastructure/src/lib/event-store/entities/event.entity.ts b/libs/api/shared/infrastructure/src/lib/event-store/entities/event.entity.ts index 289c290..b8dada8 100644 --- a/libs/api/shared/infrastructure/src/lib/event-store/entities/event.entity.ts +++ b/libs/api/shared/infrastructure/src/lib/event-store/entities/event.entity.ts @@ -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; diff --git a/libs/api/shared/infrastructure/src/lib/event-store/event-store.relay.ts b/libs/api/shared/infrastructure/src/lib/event-store/event-store.relay.ts index fb43a61..6540c23 100644 --- a/libs/api/shared/infrastructure/src/lib/event-store/event-store.relay.ts +++ b/libs/api/shared/infrastructure/src/lib/event-store/event-store.relay.ts @@ -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') diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/automations.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/automations.repository.ts index 81745ef..56f1a9c 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/automations.repository.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/automations.repository.ts @@ -41,12 +41,14 @@ export class MongoAutomationsRepository extends AutomationsRepository { } async findById(id: string): Promise { - const doc = await this.automations.findById(id).lean(); + const doc = await this.automations + .findOne({ id: id }) + .lean(); return doc ? (fromDoc(doc) as Automation) : null; } async insert({ id, ...rest }: Automation): Promise { - 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>, ): Promise { 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 { - await this.automations.deleteOne({ _id: id }); + await this.automations.deleteOne({ id }); } async claimRun(ruleId: string, eventKey: string): Promise { 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 { await this.automations.updateOne( - { _id: id }, + { id }, { $inc: { runs: 1 }, $set: { lastRunAt: new Date().toISOString(), lastError: error }, diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 49fad10..2accac6 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -79,7 +79,7 @@ export class MongoFindProjectsRepository extends FindProjectsRepository { } async findById(id: string): Promise { - const doc = await this.projects.findById(id).lean(); + const doc = await this.projects.findOne({ id: id }).lean(); return doc ? toProject(doc) : null; } @@ -109,7 +109,7 @@ export class MongoFindProjectsRepository extends FindProjectsRepository { userId: string, ): Promise { const doc = await this.projects - .findOne({ _id: projectId, memberIds: userId }, { members: 1 }) + .findOne({ id: projectId, memberIds: userId }, { members: 1 }) .lean>(); 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 { - const doc = await this.issues.findById(id).lean(); + const doc = await this.issues.findOne({ id: id }).lean(); return doc ? issueFromDoc(doc) : null; } @@ -136,7 +136,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { async findByIds(ids: string[]): Promise { if (ids.length === 0) return []; const docs = await this.issues - .find({ _id: { $in: ids } }) + .find({ id: { $in: ids } }) .lean(); 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(); 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 { - const doc = await this.comments.findById(id).lean(); + const doc = await this.comments.findOne({ id: id }).lean(); return doc ? commentFromDoc(doc) : null; } @@ -496,7 +496,7 @@ export class MongoFindReleasesRepository extends FindReleasesRepository { } async findById(id: string): Promise { - const doc = await this.releases.findById(id).lean(); + const doc = await this.releases.findOne({ id: id }).lean(); return doc ? fromDoc(doc) : null; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts index d53d230..451e0cf 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts @@ -21,7 +21,7 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository { async repositoriesOf(projectId: string): Promise { const doc = await this.integrations - .findById(projectId) + .findOne({ id: projectId }) .lean(); if (!doc) return []; if (doc.repositories) return doc.repositories; @@ -44,11 +44,11 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository { repositories: GitRepositoryConfig[], ): Promise { 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 }, ); diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/notifications.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/notifications.repository.ts index a0c2d62..f7a8325 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/notifications.repository.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/notifications.repository.ts @@ -26,7 +26,7 @@ export class MongoNotificationsRepository extends NotificationsRepository { async insert({ id, ...rest }: NotificationReadModel): Promise { 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 { 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 { const doc = await this.settingsDocs - .findById(userId) + .findOne({ id: userId }) .lean(); return { userId, email: doc?.email ?? false }; } async saveSettings(settings: NotificationSettingsReadModel): Promise { await this.settingsDocs.updateOne( - { _id: settings.userId }, + { id: settings.userId }, { $set: { email: settings.email } }, { upsert: true }, ); diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/outgoing-webhooks.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/outgoing-webhooks.repository.ts index e14097c..2083ed2 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/outgoing-webhooks.repository.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/outgoing-webhooks.repository.ts @@ -33,12 +33,14 @@ export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository } async findById(id: string): Promise { - const doc = await this.webhooks.findById(id).lean(); + const doc = await this.webhooks + .findOne({ id: id }) + .lean(); return doc ? (fromDoc(doc) as OutgoingWebhook) : null; } async insert({ id, ...rest }: OutgoingWebhook): Promise { - 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>, ): Promise { 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 { - await this.webhooks.deleteOne({ _id: id }); + await this.webhooks.deleteOne({ id }); } async recordDelivery(id: string, delivery: WebhookDelivery): Promise { - await this.webhooks.updateOne( - { _id: id }, - { $set: { lastDelivery: delivery } }, - ); + await this.webhooks.updateOne({ id }, { $set: { lastDelivery: delivery } }); } } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/rank.backfill.ts b/libs/api/tracker/infrastructure/src/lib/mongo/rank.backfill.ts index 3daf5c8..358f22b 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/rank.backfill.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/rank.backfill.ts @@ -18,13 +18,13 @@ export class RankBackfill implements OnApplicationBootstrap { async onApplicationBootstrap(): Promise { const missing = await this.issues - .find({ rank: { $exists: false } }, { number: 1 }) - .lean[]>(); + .find({ rank: { $exists: false } }, { id: 1, number: 1 }) + .lean[]>(); 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) } }, }, })), diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 9e1d34e..2e3f79b 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -15,11 +15,16 @@ import { SprintReadModel, implicitRank, } 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. */ -type Doc = Omit & { _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 & { _id?: Types.ObjectId }; export type ProjectDoc = Doc & { memberIds: string[] }; /** Links are split: outward ones come from the issue itself, inward ones from other issues' events. */ @@ -34,11 +39,11 @@ export type ActivityDoc = Doc; export type TimelineDoc = Doc; export type NotificationDoc = Doc; export type SavedFilterDoc = Doc; -export type OutgoingWebhookDoc = Omit & { _id: string }; -export type AutomationDoc = Omit & { _id: string }; +export type OutgoingWebhookDoc = Doc; +export type AutomationDoc = Doc; export interface AutomationRunDoc { /** `ruleId:eventKey`: a rule runs once per event. */ - _id: string; + id: string; at: Date; } export interface GitRepositoryDoc { @@ -52,13 +57,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; } @@ -88,11 +95,13 @@ const link = { issueId: 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( { - _id: String, + id: domainId, key: { type: String, unique: true }, isPublic: { type: Boolean, index: true }, memberIds: { type: [String], index: true }, @@ -105,7 +114,7 @@ export const ProjectSchema = new Schema( export const IssueSchema = new Schema( { - _id: String, + id: domainId, projectId: { type: String, index: true }, key: { type: String, unique: true }, status: String, @@ -149,7 +158,7 @@ IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); export const CommentSchema = new Schema( { - _id: String, + id: domainId, issueId: { type: String, index: true }, replyTo: { type: String, index: true }, author: userRef, @@ -160,13 +169,13 @@ export const CommentSchema = new Schema( CommentSchema.index({ issueId: 1, createdAt: 1 }); export const ReleaseSchema = new Schema( - { _id: String, projectId: { type: String, index: true }, version: Number }, + { id: domainId, projectId: { type: String, index: true }, version: Number }, { ...loose, collection: 'releases_view' }, ); export const SprintSchema = new Schema( { - _id: String, + id: domainId, projectId: { type: String, index: true }, status: String, version: Number, @@ -176,7 +185,7 @@ export const SprintSchema = new Schema( export const ActivitySchema = new Schema( { - _id: String, + id: domainId, projectId: String, issueId: String, actor: userRef, @@ -188,14 +197,14 @@ ActivitySchema.index({ issueId: 1, occurredAt: -1 }); /** Every status and release step of every ticket (statistics). */ export const TimelineSchema = new Schema( - { _id: String, projectId: String, issueId: String, at: String }, + { id: domainId, projectId: String, issueId: String, at: String }, { ...loose, collection: 'issue_timeline' }, ); TimelineSchema.index({ projectId: 1, at: 1 }); export const NotificationSchema = new Schema( { - _id: String, + id: domainId, userId: String, readAt: String, createdAt: String, @@ -207,13 +216,13 @@ NotificationSchema.index({ userId: 1, createdAt: -1 }); NotificationSchema.index({ userId: 1, readAt: 1 }); export const NotificationSettingsSchema = new Schema( - { _id: String, email: Boolean }, + { id: domainId, email: Boolean }, { ...loose, collection: 'notification_settings' }, ); export const SavedFilterSchema = new Schema( { - _id: String, + id: domainId, projectId: String, owner: userRef, name: String, @@ -226,13 +235,13 @@ SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 }); SavedFilterSchema.index({ projectId: 1, shared: 1 }); export const GitIntegrationSchema = new Schema( - { _id: String, repositories: [Object], secret: String, createdAt: String }, + { id: domainId, repositories: [Object], secret: String, createdAt: String }, { ...loose, collection: 'git_integrations' }, ); export const OutgoingWebhookSchema = new Schema( { - _id: String, + id: domainId, projectId: { type: String, index: true }, active: Boolean, events: [String], @@ -244,7 +253,7 @@ export const OutgoingWebhookSchema = new Schema( export const AutomationSchema = new Schema( { - _id: String, + id: domainId, projectId: { type: String, index: true }, active: Boolean, createdBy: userRef, @@ -254,7 +263,7 @@ export const AutomationSchema = new Schema( ); export const AutomationRunSchema = new Schema( - { _id: String, at: Date }, + { id: domainId, at: Date }, { ...loose, collection: 'automation_runs' }, ); // Redeliveries happen within minutes; a month of memory is plenty. @@ -277,17 +286,17 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.SavedFilter, schema: SavedFilterSchema }, ]; -export function fromDoc( - doc: T, -): Omit & { 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( + 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. diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/saved-filters.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/saved-filters.repository.ts index 54dabb4..32c178d 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/saved-filters.repository.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/saved-filters.repository.ts @@ -26,7 +26,7 @@ export class MongoSavedFiltersRepository extends SavedFiltersRepository { } async findById(id: string): Promise { - const doc = await this.filters.findById(id).lean(); + const doc = await this.filters.findOne({ id: id }).lean(); return doc ? toFilter(doc) : null; } @@ -35,11 +35,11 @@ export class MongoSavedFiltersRepository extends SavedFiltersRepository { } async insert({ id, ...rest }: SavedFilterReadModel): Promise { - await this.filters.create({ _id: id, ...rest }); + await this.filters.create({ id, ...rest }); } async delete(id: string): Promise { - await this.filters.deleteOne({ _id: id }); + await this.filters.deleteOne({ id }); } } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts index 5fcccd4..fc5e614 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts @@ -24,7 +24,7 @@ export class MongoFindSprintsRepository extends FindSprintsRepository { } async findById(id: string): Promise { - const doc = await this.sprints.findById(id).lean(); + const doc = await this.sprints.findOne({ id: id }).lean(); 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 }); } } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/status-category.backfill.ts b/libs/api/tracker/infrastructure/src/lib/mongo/status-category.backfill.ts index 23f10a1..97f945c 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/status-category.backfill.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/status-category.backfill.ts @@ -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[]>(); + .find({ id: { $in: projectIds } }, { id: 1, settings: 1 }) + .lean[]>(); const workflows = new Map( projects.map((p) => [ - p._id, + p.id, p.settings ? new ProjectWorkflow(p.settings) : ProjectWorkflow.default(), diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-if-newer.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-if-newer.ts index dbbf795..5de41dc 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-if-newer.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-if-newer.ts @@ -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( +export async function upsertIfNewer( model: Model, id: string, version: number, @@ -13,13 +13,13 @@ export async function upsertIfNewer( ): Promise { 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; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index 3c1bf74..28dbf39 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -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 { 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 { await this.timeline.updateOne( - { _id: id }, + { id }, { $setOnInsert: fields }, { upsert: true }, ); diff --git a/libs/api/tracker/infrastructure/src/lib/postgres/lookup.entities.ts b/libs/api/tracker/infrastructure/src/lib/postgres/lookup.entities.ts index 24fd5b4..196ea71 100644 --- a/libs/api/tracker/infrastructure/src/lib/postgres/lookup.entities.ts +++ b/libs/api/tracker/infrastructure/src/lib/postgres/lookup.entities.ts @@ -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 }) diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index 7c87028..3fcffd4 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -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 { + 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 }), diff --git a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts index e9b39a1..f110485 100644 --- a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts @@ -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)" > - + (checkedChange)="toggle(issue.id, $event)" + >Seleccionar {{ issue.key }} {{ i + 1 }} - @@ -512,15 +519,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; [options]="linkTypeOptions" [(value)]="linkType" /> - @@ -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(IssueLinkType.RELATES_TO); - protected readonly linkTarget = signal(''); + protected readonly linkTarget = signal(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(null); @@ -1133,7 +1169,7 @@ export class IssuePage { protected readonly checklistText = signal(''); protected readonly subtaskTitle = signal(''); protected readonly subtaskType = signal(null); - protected readonly parentKey = signal(''); + protected readonly parentKey = signal(null); /** Active types; subtasks start as tasks when the project has them. */ protected readonly subtaskTypeOptions = computed< PiSelectFieldOption[] @@ -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 { diff --git a/libs/web/tracker/presenters/src/lib/pages/list.page.ts b/libs/web/tracker/presenters/src/lib/pages/list.page.ts index e63ab99..6c6ab53 100644 --- a/libs/web/tracker/presenters/src/lib/pages/list.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/list.page.ts @@ -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()); @if (project.canContribute()) { - + (checkedChange)="toggle(issue.id, $event)" + >Seleccionar {{ issue.key }} } diff --git a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts index ba1d95d..6cde1ee 100644 --- a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts @@ -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'; > Descripción -