feat: archive a project instead of deleting it
The owner archives a finished project (archiveProject, POST
/projects/{project}/archive) and brings it back (unarchive). While
archived it stays readable, managers still read its rules and webhooks,
but no command changes it: contributing and managing checks refuse with
422, votes and public proposals too, and automations do not run. The web
shows a read-only notice on every page of it (with «Desarchivar» for the
owner), hides what edits, marks it on the home page and lists it last.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,9 +96,12 @@ async function managedProject(
|
||||
access: WriteAccess,
|
||||
projectId: string,
|
||||
actorId: string,
|
||||
/** Only to read the rules: an archived project still shows them. */
|
||||
reading = false,
|
||||
): Promise<Project> {
|
||||
const project = await access.project(projectId);
|
||||
project.assertCanManage(actorId);
|
||||
if (reading) project.assertManages(actorId);
|
||||
else project.assertCanManage(actorId);
|
||||
return project;
|
||||
}
|
||||
|
||||
@@ -231,6 +234,7 @@ export class RunAutomationCommandHandler
|
||||
? await this.access.live(issue.projectId, Project)
|
||||
: null;
|
||||
if (!issue || !project) return 'The ticket is gone';
|
||||
if (project.archivedAt) return 'The project is archived';
|
||||
const { kind, value } = command.automation.action;
|
||||
const actor = SYSTEM_ACTOR_ID;
|
||||
switch (kind) {
|
||||
@@ -297,7 +301,7 @@ export class GetAutomationsQueryHandler
|
||||
) {}
|
||||
|
||||
async execute(query: GetAutomationsQuery): Promise<Automation[]> {
|
||||
await managedProject(this.access, query.projectId, query.viewerId);
|
||||
await managedProject(this.access, query.projectId, query.viewerId, true);
|
||||
return this.automations.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ArchiveProjectCommand } from './archive-project.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(ArchiveProjectCommand)
|
||||
export class ArchiveProjectCommandHandler
|
||||
implements ICommandHandler<ArchiveProjectCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: ArchiveProjectCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
if (command.archived) project.archive(command.actorId);
|
||||
else project.unarchive(command.actorId);
|
||||
await project.commitAndWait();
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** The owner archives the project (read-only) or, with `archived` false, brings it back. */
|
||||
export class ArchiveProjectCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly archived: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { UpdateReleaseCommandHandler } from './update-release.command-handler';
|
||||
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
|
||||
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
|
||||
import { DeleteProjectCommandHandler } from './delete-project.command-handler';
|
||||
import { ArchiveProjectCommandHandler } from './archive-project.command-handler';
|
||||
import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
|
||||
import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
|
||||
import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
|
||||
@@ -66,6 +67,7 @@ export const COMMAND_HANDLERS = [
|
||||
PublishReleaseCommandHandler,
|
||||
ArchiveReleaseCommandHandler,
|
||||
DeleteProjectCommandHandler,
|
||||
ArchiveProjectCommandHandler,
|
||||
// Emitted by sagas only (cascades and reactions to other aggregates).
|
||||
DeleteIssueCommandHandler,
|
||||
DeleteReleaseCommandHandler,
|
||||
|
||||
@@ -24,6 +24,7 @@ export class VoteIssueCommandHandler
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
project.assertNotArchived();
|
||||
if (issue.trashedAt)
|
||||
throw new BusinessRuleError(`${issue.key} is in the trash`);
|
||||
issue.vote(command.actorId, command.on);
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
ProjectRole,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ProjectWorkflow,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
@@ -109,6 +111,10 @@ export function describeEvent(
|
||||
return `${actor} creó el proyecto ${event.project.key}`;
|
||||
if (event instanceof ProjectUpdatedEvent)
|
||||
return `${actor} actualizó el proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectArchivedEvent)
|
||||
return `${actor} archivó el proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectUnarchivedEvent)
|
||||
return `${actor} desarchivó el proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectSettingsChangedEvent)
|
||||
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectMemberAddedEvent) {
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ProjectMemberRemovedEvent,
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
IssueSprintChangedEvent,
|
||||
@@ -57,6 +59,8 @@ import { versionOf } from './read-model-notifier';
|
||||
@EventsHandler(
|
||||
ProjectCreatedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectMemberRemovedEvent,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ProjectMemberAddedEventHandler } from './project-member-added.event-han
|
||||
import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler';
|
||||
import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler';
|
||||
import { ProjectUpdatedEventHandler } from './project-updated.event-handler';
|
||||
import { ProjectArchivedEventHandler } from './project-archived.event-handler';
|
||||
import { ReleaseArchivedEventHandler } from './release-archived.event-handler';
|
||||
import { ReleaseCreatedEventHandler } from './release-created.event-handler';
|
||||
import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
|
||||
@@ -63,6 +64,7 @@ export const EVENT_HANDLERS = [
|
||||
ProjectMemberRemovedEventHandler,
|
||||
ProjectMemberRoleChangedEventHandler,
|
||||
ProjectUpdatedEventHandler,
|
||||
ProjectArchivedEventHandler,
|
||||
ProjectSettingsChangedEventHandler,
|
||||
ReleaseArchivedEventHandler,
|
||||
ReleaseCreatedEventHandler,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ProjectArchivedEvent, ProjectUnarchivedEvent)
|
||||
export class ProjectArchivedEventHandler
|
||||
implements IEventHandler<ProjectArchivedEvent | ProjectUnarchivedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectArchivedEvent | ProjectUnarchivedEvent) {
|
||||
await this.upsertMaterializedProjectRepository.upsert({
|
||||
id: event.projectId,
|
||||
archivedAt:
|
||||
event instanceof ProjectArchivedEvent ? event.occurredAt : null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.project(event);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { ChangeProjectMemberRoleCommand } from './commands/change-project-member
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { CreateProjectCommand } from './commands/create-project.command';
|
||||
import { DeleteProjectCommand } from './commands/delete-project.command';
|
||||
import { ArchiveProjectCommand } from './commands/archive-project.command';
|
||||
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
|
||||
import { UpdateProjectCommand } from './commands/update-project.command';
|
||||
import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command';
|
||||
@@ -75,7 +76,7 @@ export class ProjectsService {
|
||||
(await this.findProjects.findById(projectIdOrKey)) ??
|
||||
(await this.findProjects.findByKey(projectIdOrKey.toUpperCase()));
|
||||
if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey);
|
||||
if (!found.acceptsProposals)
|
||||
if (!found.acceptsProposals || found.archivedAt)
|
||||
throw new ForbiddenError(`${found.key} does not take proposals`);
|
||||
|
||||
const cleanTitle = title.trim();
|
||||
@@ -170,7 +171,8 @@ export class ProjectsService {
|
||||
automations: Automation[];
|
||||
}> {
|
||||
const project = await this.access.project(projectId);
|
||||
project.assertCanManage(actorId);
|
||||
// Copying only reads it: an archived project makes a fine template.
|
||||
project.assertManages(actorId);
|
||||
const automations: Automation[] = await this.queryBus.execute(
|
||||
new GetAutomationsQuery(actorId, projectId),
|
||||
);
|
||||
@@ -241,6 +243,17 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
/** Statuses, transitions, ticket types and areas (owners and admins). */
|
||||
/** Archives the project (read-only) or brings it back; its owner only. */
|
||||
setArchived(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
archived: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new ArchiveProjectCommand(actorId, projectId, archived),
|
||||
);
|
||||
}
|
||||
|
||||
updateSettings(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
|
||||
@@ -327,9 +327,7 @@ export class GetWebhooksQueryHandler
|
||||
) {}
|
||||
|
||||
async execute(query: GetWebhooksQuery): Promise<WebhookView[]> {
|
||||
(await this.access.project(query.projectId)).assertCanManage(
|
||||
query.viewerId,
|
||||
);
|
||||
(await this.access.project(query.projectId)).assertManages(query.viewerId);
|
||||
return (await this.webhooks.findByProject(query.projectId)).map(view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export * from './release-updated.event';
|
||||
export * from './release-shipped.event';
|
||||
export * from './release-archived.event';
|
||||
export * from './project-deleted.event';
|
||||
export * from './project-archived.event';
|
||||
export * from './issue-deleted.event';
|
||||
export * from './release-deleted.event';
|
||||
export * from './issue-attachment-added.event';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** The owner archived the project: read-only until it is unarchived. */
|
||||
@AutowiredEvent
|
||||
export class ProjectArchivedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, projectId: string) {
|
||||
super(actorId, projectId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** The owner brought an archived project back. */
|
||||
@AutowiredEvent
|
||||
export class ProjectUnarchivedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, projectId: string) {
|
||||
super(actorId, projectId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -106,4 +106,28 @@ describe('Deleting a project', () => {
|
||||
).toHaveLength(2);
|
||||
expect(source.links.map((l) => l.targetIssueId)).toEqual(['o']);
|
||||
});
|
||||
|
||||
it('archives a project read-only, and only its owner brings it back', () => {
|
||||
const project = newProject();
|
||||
expect(() => project.archive('admin')).toThrow(ForbiddenError);
|
||||
project.archive('owner');
|
||||
expect(project.archivedAt).not.toBeNull();
|
||||
// Idempotent.
|
||||
const events = project.getUncommittedEvents().length;
|
||||
project.archive('owner');
|
||||
expect(project.getUncommittedEvents()).toHaveLength(events);
|
||||
|
||||
// Nothing changes, but who manages it can still read what only they see.
|
||||
expect(() => project.assertCanContribute('admin')).toThrow(/archivado/);
|
||||
expect(() => project.updateDetails('owner', { name: 'Otra' })).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
expect(() => project.assertManages('admin')).not.toThrow();
|
||||
expect(() => project.unarchive('admin')).toThrow(ForbiddenError);
|
||||
|
||||
project.unarchive('owner');
|
||||
expect(project.archivedAt).toBeNull();
|
||||
expect(() => project.assertCanContribute('admin')).not.toThrow();
|
||||
expect(replay(project, Project).rebuilt.archivedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
VersionedAggregateRoot,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
} from '../events/project-archived.event';
|
||||
import { ProjectCreatedEvent } from '../events/project-created.event';
|
||||
import { ProjectDeletedEvent } from '../events/project-deleted.event';
|
||||
import { ProjectMemberAddedEvent } from '../events/project-member-added.event';
|
||||
@@ -62,6 +66,8 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
public updatedAt!: string;
|
||||
/** Set once deleted: the project can no longer be loaded for commands. */
|
||||
public deleted = false;
|
||||
/** Archived since then: read-only until its owner brings it back. */
|
||||
public archivedAt: string | null = null;
|
||||
|
||||
constructor(id: string) {
|
||||
super();
|
||||
@@ -96,10 +102,20 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
if (this.roleOf(userId) === ProjectRole.VIEWER) {
|
||||
throw new ForbiddenError(`Viewers cannot modify project ${this.key}`);
|
||||
}
|
||||
this.assertNotArchived();
|
||||
}
|
||||
|
||||
/** Can manage members, releases and project settings. */
|
||||
/** Can change members, releases, settings and rules (owners and admins). */
|
||||
assertCanManage(userId: string): void {
|
||||
this.assertManages(userId);
|
||||
this.assertNotArchived();
|
||||
}
|
||||
|
||||
/**
|
||||
* Is an owner or an admin: enough to read what only they see (rules,
|
||||
* webhooks…), archived or not. To change anything, assertCanManage.
|
||||
*/
|
||||
assertManages(userId: string): void {
|
||||
const role = this.roleOf(userId);
|
||||
if (role !== ProjectRole.OWNER && role !== ProjectRole.ADMIN) {
|
||||
throw new ForbiddenError(
|
||||
@@ -108,6 +124,36 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
}
|
||||
}
|
||||
|
||||
/** An archived project only changes back to active (by its owner). */
|
||||
assertNotArchived(): void {
|
||||
if (this.archivedAt)
|
||||
throw new BusinessRuleError(
|
||||
`${this.key} está archivado: es de solo lectura hasta que su propietario lo desarchive`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the owner archives a project: everything stays readable, nothing
|
||||
* changes until they unarchive it. Idempotent.
|
||||
*/
|
||||
archive(actorId: string): void {
|
||||
if (actorId !== this.ownerId)
|
||||
throw new ForbiddenError(
|
||||
`Only the owner can archive project ${this.key}`,
|
||||
);
|
||||
if (this.archivedAt) return;
|
||||
this.apply(new ProjectArchivedEvent(actorId, this.id));
|
||||
}
|
||||
|
||||
unarchive(actorId: string): void {
|
||||
if (actorId !== this.ownerId)
|
||||
throw new ForbiddenError(
|
||||
`Only the owner can unarchive project ${this.key}`,
|
||||
);
|
||||
if (!this.archivedAt) return;
|
||||
this.apply(new ProjectUnarchivedEvent(actorId, this.id));
|
||||
}
|
||||
|
||||
updateDetails(
|
||||
actorId: string,
|
||||
changes: {
|
||||
@@ -249,6 +295,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
onProjectArchivedEvent(event: ProjectArchivedEvent): void {
|
||||
this.archivedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onProjectUnarchivedEvent(): void {
|
||||
this.archivedAt = null;
|
||||
}
|
||||
|
||||
onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void {
|
||||
this.settings = structuredClone(event.settings);
|
||||
this.updatedAt = event.occurredAt;
|
||||
|
||||
@@ -18,6 +18,8 @@ export class ProjectReadModel {
|
||||
acceptsProposals!: boolean;
|
||||
owner!: UserRefReadModel;
|
||||
members!: ProjectMemberReadModel[];
|
||||
/** Archived since then (read-only); null while active. */
|
||||
archivedAt!: string | null;
|
||||
/** Statuses, transitions, ticket types and areas. */
|
||||
settings!: ProjectSettings;
|
||||
createdAt!: string;
|
||||
|
||||
@@ -66,6 +66,7 @@ function toProject(doc: ProjectDoc): ProjectReadModel {
|
||||
isPublic: rest.isPublic ?? false,
|
||||
acceptsProposals: rest.acceptsProposals ?? false,
|
||||
settings: rest.settings ?? defaultProjectSettings(),
|
||||
archivedAt: rest.archivedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,27 @@ export class ProjectResolver {
|
||||
return this.projects.delete(me.id, projectId, confirmKey);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Archive the project (owner only): everything stays readable, nothing changes until it is unarchived',
|
||||
})
|
||||
archiveProject(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.projects.setArchived(me.id, projectId, true);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Bring an archived project back (owner only)',
|
||||
})
|
||||
unarchiveProject(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.projects.setArchived(me.id, projectId, false);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Add a Pireon user to the project',
|
||||
})
|
||||
|
||||
@@ -137,6 +137,11 @@ export class ProjectGql {
|
||||
@Field(() => UserRefGql) owner!: UserRefGql;
|
||||
@Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[];
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Archived since then (read-only); null while active',
|
||||
})
|
||||
archivedAt!: string | null;
|
||||
@Field() updatedAt!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,13 @@ export class ProjectDto {
|
||||
@ApiProperty({ type: ProjectSettingsDto })
|
||||
settings!: ProjectSettingsDto;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'date-time',
|
||||
description: 'Archived since then (read-only); null while active',
|
||||
})
|
||||
archivedAt!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||
@ApiProperty({ description: 'Version of the aggregate (event count)' })
|
||||
version!: number;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
HttpCode,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
@@ -148,6 +149,38 @@ export class ProjectsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':project/archive')
|
||||
@HttpCode(200)
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Archive a project (owner only)',
|
||||
description:
|
||||
'It stays readable (board, tickets, history, statistics); nothing can change until it is unarchived.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async archive(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return this.projects.setArchived(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':project/unarchive')
|
||||
@HttpCode(200)
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Bring an archived project back (owner only)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async unarchive(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return this.projects.setArchived(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':project/settings')
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
|
||||
Reference in New Issue
Block a user