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