feat: teams within a project

Projects keep teams of members in their settings (settings.teams; people
outside the project are dropped, teams are archived, not removed; absent
when saving means unchanged). Tickets go to a team (`team`, also filtered
in the list and in MCP search_issues), and a comment mentioning
@[Frontend](team-FRONT) tells each of its members. Settings has the teams
with their members, the ticket a «Equipo» select, the list a team filter,
and «@» offers the teams after the people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:20:35 +02:00
parent 4acf629106
commit 869acd3f1f
41 changed files with 895 additions and 13 deletions

View File

@@ -86,6 +86,7 @@ const FIELD: Record<string, string> = {
description: 'la descripción',
type: 'el tipo',
area: 'el área',
team: 'el equipo',
priority: 'la prioridad',
labels: 'las etiquetas',
figmaUrl: 'el enlace de Figma',

View File

@@ -41,6 +41,7 @@ export class IssueCreatedEventHandler
key: issue.key,
type: issue.type,
area: issue.area,
team: issue.team ?? null,
title: issue.title,
description: issue.description,
status: issue.status,

View File

@@ -350,6 +350,7 @@ export class IssuesService {
title: `${issue.title} (copia)`.slice(0, 255),
description: issue.description,
area: issue.area,
team: issue.team,
priority: issue.priority,
labels: issue.labels,
figmaUrl: issue.figmaUrl,

View File

@@ -8,6 +8,9 @@ import {
IssueStatusChangedEvent,
IssueUpdatedEvent,
mentionedUserIds,
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
TEAM_MENTION_PREFIX,
NotificationKind,
ProjectMemberAddedEvent,
ProjectRole,
@@ -195,7 +198,12 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
if (event instanceof CommentAddedEvent) {
const said = `«${excerpt(event.comment.body)}»`;
// Only members: a mention cannot reach someone who cannot read the ticket.
for (const userId of mentionedUserIds(event.comment.body)) {
// A team's mention (`team-FRONTEND`) reaches each of its members.
const workflow = new ProjectWorkflow(
project.settings ?? DEFAULT_PROJECT_SETTINGS,
);
const named = mentionedUserIds(event.comment.body);
for (const userId of named) {
if (!memberIds.has(userId)) continue;
drafts.push({
userId,
@@ -203,6 +211,19 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
});
}
for (const id of named.filter((n) => n.startsWith(TEAM_MENTION_PREFIX))) {
const team = workflow.teams.find(
(t) => t.id === id.slice(TEAM_MENTION_PREFIX.length),
);
for (const userId of workflow.mentionedPeople([id])) {
if (!memberIds.has(userId)) continue;
drafts.push({
userId,
kind: NotificationKind.MENTIONED,
summary: `${actor} mencionó a tu equipo ${team?.name ?? ''} en ${issue.key}: ${said}`,
});
}
}
const answered = event.comment.replyTo
? await this.findComments.findById(event.comment.replyTo)
: null;

View File

@@ -20,6 +20,8 @@ export interface IssueFilter {
categories?: StatusCategory[];
types?: string[];
areas?: string[];
/** Team ids of the project's settings. */
teams?: string[];
priorities?: IssuePriority[];
assigneeId?: string;
unassigned?: boolean;

View File

@@ -44,6 +44,7 @@ export class IssueFactory {
const details = normalizeIssueDetails(props);
workflow.assertActiveType(props.type);
workflow.assertActiveArea(props.area);
workflow.assertActiveTeam(props.team);
const title = details.title ?? '';
assertThat(title.length > 0, 'Issue title is required');
if (details.startDate && details.dueDate)
@@ -60,6 +61,7 @@ export class IssueFactory {
key: `${props.projectKey}-${props.number}`,
type: props.type,
area: props.area ?? null,
team: props.team ?? null,
title,
description: details.description ?? null,
status: startingStatus(props.status, workflow),

View File

@@ -56,6 +56,8 @@ export interface IssueDetails {
type?: string;
/** Id of one of the project's areas. */
area?: string | null;
/** Id of one of the project's teams. */
team?: string | null;
priority?: IssuePriority;
labels?: string[];
figmaUrl?: string | null;
@@ -83,6 +85,8 @@ export interface IssueProps {
key: string;
type: string;
area: string | null;
/** Absent in tickets created before teams. */
team?: string | null;
title: string;
description: string | null;
/** Id of a status of the project's workflow. */
@@ -113,6 +117,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public key!: string;
public type!: string;
public area: string | null = null;
public team: string | null = null;
public title!: string;
public description: string | null = null;
public status!: string;
@@ -190,6 +195,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
assertThat(patch.title.length > 0, 'Issue title is required');
if (patch.type !== undefined) workflow.assertActiveType(patch.type);
if (patch.area !== undefined) workflow.assertActiveArea(patch.area);
if (patch.team !== undefined) workflow.assertActiveTeam(patch.team);
if (Object.keys(patch).length === 0) return;
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
}

View File

@@ -403,4 +403,55 @@ describe('Project settings', () => {
});
expect(project.workflow.checklistBeforeDone).toBe(false);
});
it('keeps teams of members, assigns tickets to them and expands their mentions', () => {
const project = newProject();
project.updateSettings('owner', {
...defaultProjectSettings(),
teams: [
{
id: 'front',
name: ' Frontend ',
memberIds: ['dev', 'dev', 'stranger'],
archived: false,
},
],
});
// Normalised, and only people of the project.
expect(project.workflow.teams).toEqual([
{ id: 'FRONT', name: 'Frontend', memberIds: ['dev'], archived: false },
]);
// Absent: unchanged; removing one: refused (archive it instead).
project.updateSettings('owner', defaultProjectSettings());
expect(project.workflow.teams).toHaveLength(1);
expect(() =>
project.updateSettings('owner', {
...defaultProjectSettings(),
teams: [],
}),
).toThrow(/archívalo/);
expect(
project.workflow.mentionedPeople(['owner', 'team-FRONT', 'team-NOPE']),
).toEqual(['owner', 'dev']);
const issue = new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Botón',
type: 'TASK',
reporterId: 'dev',
team: 'FRONT',
},
project.workflow,
);
expect(issue.team).toBe('FRONT');
expect(() =>
issue.updateDetails('dev', { team: 'NOPE' }, project.workflow),
).toThrow(ValidationError);
issue.updateDetails('dev', { team: null }, project.workflow);
expect(issue.team).toBeNull();
});
});

View File

@@ -94,6 +94,22 @@ export interface AreaOption {
archived: boolean;
}
/**
* A group of the project's members (Frontend, QA…): tickets can go to it and
* `@[Frontend](team-FRONTEND)` tells all of them.
*/
export interface TeamDefinition {
id: string;
name: string;
/** User ids; people who leave the project are dropped. */
memberIds: string[];
archived: boolean;
}
/** Prefix of a team's id in a mention: `@[Frontend](team-FRONTEND)`. */
export const TEAM_MENTION_PREFIX = 'team-';
export const MAX_TEAMS = 30;
/** What a custom field holds. */
export enum CustomFieldType {
TEXT = 'TEXT',
@@ -140,6 +156,8 @@ export interface ProjectSettings {
* off; when saving, absent means unchanged.
*/
checklistBeforeDone?: boolean;
/** Absent in projects that never defined any; when saving, absent means unchanged. */
teams?: TeamDefinition[];
}
const status = (
@@ -369,6 +387,34 @@ export class ProjectWorkflow {
);
}
/** The project's teams, archived ones included. */
get teams(): TeamDefinition[] {
return this.settings.teams ?? [];
}
assertActiveTeam(id: string | null | undefined): void {
if (id === null || id === undefined) return;
const team = this.teams.find((t) => t.id === id);
if (!team) throw new ValidationError(`Unknown team ${id}`);
if (team.archived)
throw new BusinessRuleError(`El equipo ${team.name} está archivado`);
}
/** Who a mention id reaches: the person, or every member of the team. */
mentionedPeople(mentionIds: string[]): string[] {
return [
...new Set(
mentionIds.flatMap((id) =>
id.startsWith(TEAM_MENTION_PREFIX)
? (this.teams.find(
(t) => t.id === id.slice(TEAM_MENTION_PREFIX.length),
)?.memberIds ?? [])
: [id],
),
),
];
}
assertActiveArea(id: string | null | undefined): void {
if (id === null || id === undefined) return;
const area = this.settings.areas.find((a) => a.id === id);
@@ -414,6 +460,20 @@ export function validateProjectSettings(
name: normName(a.name),
archived: !!a.archived,
}));
const teams = (next.teams ?? previous.teams ?? []).map((t) => ({
id: normId(t.id),
name: normName(t.name),
memberIds: [
...new Set(
(t.memberIds ?? []).map((m) => String(m).trim()).filter(Boolean),
),
].slice(0, 200),
archived: !!t.archived,
}));
assertThat(
teams.length <= MAX_TEAMS,
`Un proyecto tiene como mucho ${MAX_TEAMS} equipos`,
);
const customFields = (next.customFields ?? previous.customFields ?? []).map(
normCustomField,
);
@@ -434,6 +494,7 @@ export function validateProjectSettings(
['tipo', issueTypes, previous.issueTypes],
['área', areas, previous.areas],
['campo', customFields, previous.customFields ?? []],
['equipo', teams, previous.teams ?? []],
] as const) {
const ids = list.map((item) => item.id);
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
@@ -517,6 +578,7 @@ export function validateProjectSettings(
...((next.checklistBeforeDone ?? previous.checklistBeforeDone)
? { checklistBeforeDone: true }
: {}),
...(teams.length ? { teams } : {}),
};
}

View File

@@ -216,7 +216,17 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
/** Owners and admins customise statuses, transitions, types and areas. */
updateSettings(actorId: string, next: ProjectSettings): void {
this.assertCanManage(actorId);
const settings = validateProjectSettings(next, this.settings);
const validated = validateProjectSettings(next, this.settings);
// A team only holds people of the project.
const settings = validated.teams
? {
...validated,
teams: validated.teams.map((t) => ({
...t,
memberIds: t.memberIds.filter((id) => this.isMember(id)),
})),
}
: validated;
if (JSON.stringify(settings) === JSON.stringify(this.settings)) return;
this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings));
}

View File

@@ -62,6 +62,8 @@ export class IssueReadModel {
/** Ids from the project's settings. */
type!: string;
area!: string | null;
/** A team id of the project's settings. */
team!: string | null;
title!: string;
description!: string | null;
status!: string;

View File

@@ -451,6 +451,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
query['statusCategory'] = { $in: filter.categories };
if (filter.types?.length) query['type'] = { $in: filter.types };
if (filter.areas?.length) query['area'] = { $in: filter.areas };
if (filter.teams?.length) query['team'] = { $in: filter.teams };
if (filter.priorities?.length) query['priority'] = { $in: filter.priorities };
if (filter.labels?.length)
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };

View File

@@ -428,6 +428,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number),
startDate: rest.startDate ?? null,
team: rest.team ?? null,
customFields: rest.customFields ?? {},
sprint: rest.sprint?.id ? rest.sprint : null,
// Links recorded before projects had several repositories name none.

View File

@@ -27,6 +27,13 @@ export interface SettingsPayload {
template?: { description?: string | null; checklist?: string[] } | null;
}[];
areas: { id: string; name: string; archived: boolean }[];
/** Absent when saving: unchanged. */
teams?: {
id: string;
name: string;
memberIds: string[];
archived: boolean;
}[];
/** Definition of done. Absent when saving: unchanged. */
checklistBeforeDone?: boolean;
/** Absent when saving: unchanged. */
@@ -44,6 +51,7 @@ export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
...settings,
customFields: settings.customFields ?? [],
checklistBeforeDone: !!settings.checklistBeforeDone,
teams: settings.teams ?? [],
transitions: settings.statuses.map((s) => ({
from: s.id,
to: settings.transitions[s.id] ?? [],
@@ -75,6 +83,7 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
: {}),
})),
areas: payload.areas,
...(payload.teams ? { teams: payload.teams } : {}),
...(payload.checklistBeforeDone != null
? { checklistBeforeDone: payload.checklistBeforeDone }
: {}),

View File

@@ -128,6 +128,11 @@ abstract class IssueFieldsInput {
@Matches(SETTING_ID)
area?: string | null;
@Field(() => String, { nullable: true, description: 'A team id' })
@IsOptional()
@Matches(SETTING_ID)
team?: string | null;
@Field(() => IssuePriority, { nullable: true })
@IsOptional()
@IsEnum(IssuePriority)
@@ -515,6 +520,10 @@ export class IssueFilterInput {
@IsOptional()
@IsString({ each: true })
areas?: string[];
@Field(() => [String], { nullable: true, description: 'Team ids' })
@IsOptional()
@IsString({ each: true })
teams?: string[];
@Field(() => [IssuePriority], { nullable: true })
@IsOptional()
@IsEnum(IssuePriority, { each: true })
@@ -712,6 +721,18 @@ export class AreaOptionInput {
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
}
@InputType()
export class TeamInput {
@Field() @Matches(SETTING_ID) id!: string;
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
@Field(() => [ID], { defaultValue: [] })
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
memberIds!: string[];
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
}
@InputType()
export class CustomFieldDefinitionInput {
@Field() @Matches(SETTING_ID) id!: string;
@@ -773,6 +794,17 @@ export class ProjectSettingsInput {
@Type(() => CustomFieldDefinitionInput)
customFields?: CustomFieldDefinitionInput[];
@Field(() => [TeamInput], {
nullable: true,
description: 'Absent: unchanged. Teams cannot be removed: archive them.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@ValidateNested({ each: true })
@Type(() => TeamInput)
teams?: TeamInput[];
@Field({
nullable: true,
description:

View File

@@ -104,6 +104,17 @@ export class CustomFieldValueGql {
@Field() value!: string;
}
@ObjectType('Team', {
description:
'A group of members: tickets go to it; @team mentions tell them all',
})
export class TeamGql {
@Field() id!: string;
@Field() name!: string;
@Field(() => [ID]) memberIds!: string[];
@Field() archived!: boolean;
}
@ObjectType('ProjectSettings', {
description:
'Statuses (board order), transitions, ticket types, areas and custom fields',
@@ -120,6 +131,7 @@ export class ProjectSettingsGql {
'Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)',
})
checklistBeforeDone!: boolean;
@Field(() => [TeamGql]) teams!: TeamGql[];
}
@ObjectType('Project')
@@ -366,6 +378,8 @@ export class IssueGql {
type!: string;
@Field(() => String, { nullable: true, description: 'An area id' })
area!: string | null;
@Field(() => String, { nullable: true, description: 'A team id' })
team!: string | null;
@Field() title!: string;
@Field(() => String, { nullable: true }) description!: string | null;
@Field({ description: "A status id of the project's settings" })

View File

@@ -243,6 +243,28 @@ export class AreaOptionBodyDto {
archived = false;
}
export class TeamBodyDto {
@ApiProperty({ example: 'FRONTEND' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Frontend', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiPropertyOptional({
type: [String],
description: 'Pireon user ids of project members (others are dropped)',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
memberIds: string[] = [];
@ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class CustomFieldDefinitionBodyDto {
@ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Navegador', maxLength: 40 })
@@ -319,6 +341,17 @@ export class ProjectSettingsBodyDto {
@Type(() => CustomFieldDefinitionBodyDto)
customFields?: CustomFieldDefinitionBodyDto[];
@ApiPropertyOptional({
type: [TeamBodyDto],
description: 'Absent: unchanged. Teams cannot be removed: archive them.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@ValidateNested({ each: true })
@Type(() => TeamBodyDto)
teams?: TeamBodyDto[];
@ApiPropertyOptional({
description:
'Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged',
@@ -584,6 +617,12 @@ export class IssueSearchQueryDto extends PageQueryDto {
@Matches(SETTING_ID, { each: true })
area?: string[];
@ApiPropertyOptional({ type: [String], description: 'Team ids' })
@IsOptional()
@Transform(toList)
@Matches(SETTING_ID, { each: true })
team?: string[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
@@ -729,6 +768,16 @@ class IssueFieldsDto {
@Matches(SETTING_ID)
area?: string | null;
@ApiPropertyOptional({
type: String,
nullable: true,
example: 'FRONTEND',
description: "A team id of the project's settings",
})
@IsOptional()
@Matches(SETTING_ID)
team?: string | null;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional()
@IsEnum(IssuePriority)

View File

@@ -79,6 +79,13 @@ export class AreaOptionDto {
@ApiProperty() archived!: boolean;
}
export class TeamDto {
@ApiProperty({ example: 'FRONTEND' }) id!: string;
@ApiProperty({ example: 'Frontend' }) name!: string;
@ApiProperty({ type: [String] }) memberIds!: string[];
@ApiProperty() archived!: boolean;
}
export class CustomFieldDefinitionDto {
@ApiProperty({ example: 'BROWSER' }) id!: string;
@ApiProperty({ example: 'Navegador' }) name!: string;
@@ -108,6 +115,7 @@ export class ProjectSettingsDto {
'Definition of done: tickets reach DONE only with their checklist ticked',
})
checklistBeforeDone!: boolean;
@ApiProperty({ type: [TeamDto] }) teams!: TeamDto[];
}
export class ProjectDto {
@@ -422,6 +430,8 @@ export class IssueDto {
type!: string;
@ApiProperty({ type: String, nullable: true, example: 'FRONTEND' })
area!: string | null;
@ApiProperty({ type: String, nullable: true, description: 'A team id' })
team!: string | null;
@ApiProperty() title!: string;
@ApiProperty({ type: String, nullable: true }) description!: string | null;
@ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' })

View File

@@ -165,6 +165,7 @@ export class IssuesController {
categories: query.category,
types: query.type,
areas: query.area,
teams: query.team,
priorities: query.priority,
assigneeId: query.assigneeId,
unassigned: query.unassigned,