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:
@@ -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',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 } },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) } },
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user