feat: pinned tickets, at hand on the home page and in search
Each person pins up to 20 tickets from the ticket's page («Fijar»). They
show as a strip under the projects on the home page and in the search
palette before typing. Private and without history, like the views of
«my work» (Mongo `pinned_issues`); tickets in the trash or out of reach
are left out, and when the list is full the unreadable ones are dropped
first.
API: GET /api/me/pins, PUT/DELETE /api/issues/{issue}/pin; GraphQL
myPins, pinIssue, unpinIssue and Issue.pinned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,9 @@ export * from './lib/git/git-payloads';
|
||||
export * from './lib/saved-filters/saved-filters';
|
||||
export * from './lib/saved-filters/my-views';
|
||||
export * from './lib/ports/my-views.repository';
|
||||
export * from './lib/saved-filters/pins';
|
||||
export * from './lib/ports/pins.repository';
|
||||
export * from './lib/pins.service';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
31
libs/api/tracker/application/src/lib/pins.service.ts
Normal file
31
libs/api/tracker/application/src/lib/pins.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
GetMyPinsQuery,
|
||||
IsIssuePinnedQuery,
|
||||
PinIssueCommand,
|
||||
} from './saved-filters/pins';
|
||||
|
||||
/** Pinned tickets: the few someone keeps at hand, across projects. */
|
||||
@Injectable()
|
||||
export class PinsService {
|
||||
constructor(
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
/** Mine, most recently pinned first. */
|
||||
mine(ownerId: string): Promise<IssueReadModel[]> {
|
||||
return this.queryBus.execute(new GetMyPinsQuery(ownerId));
|
||||
}
|
||||
|
||||
isPinned(ownerId: string, issueId: string): Promise<boolean> {
|
||||
return this.queryBus.execute(new IsIssuePinnedQuery(ownerId, issueId));
|
||||
}
|
||||
|
||||
pin(ownerId: string, issueId: string, pinned: boolean): Promise<void> {
|
||||
return this.commands.execute(new PinIssueCommand(ownerId, issueId, pinned));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Pinned tickets: one person's shortcuts, no history. */
|
||||
export abstract class PinsRepository {
|
||||
/** Most recently pinned first. */
|
||||
abstract findByOwner(ownerId: string): Promise<PinnedIssueReadModel[]>;
|
||||
abstract isPinned(ownerId: string, issueId: string): Promise<boolean>;
|
||||
abstract countByOwner(ownerId: string): Promise<number>;
|
||||
/** Pinning again keeps the first date. */
|
||||
abstract pin(pin: PinnedIssueReadModel): Promise<void>;
|
||||
abstract unpin(ownerId: string, issueId: string): Promise<void>;
|
||||
}
|
||||
142
libs/api/tracker/application/src/lib/saved-filters/pins.spec.ts
Normal file
142
libs/api/tracker/application/src/lib/saved-filters/pins.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
CommandBus,
|
||||
CqrsModule,
|
||||
IQueryHandler,
|
||||
QueryBus,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssueReadModel,
|
||||
PinnedIssueReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { PinsRepository } from '../ports/pins.repository';
|
||||
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||
import {
|
||||
GetMyPinsQuery,
|
||||
IsIssuePinnedQuery,
|
||||
MAX_PINS,
|
||||
PINS,
|
||||
PinIssueCommand,
|
||||
} from './pins';
|
||||
|
||||
class InMemoryPins extends PinsRepository {
|
||||
rows: PinnedIssueReadModel[] = [];
|
||||
async findByOwner(ownerId: string) {
|
||||
return this.rows
|
||||
.filter((p) => p.ownerId === ownerId)
|
||||
.sort((a, b) => b.pinnedAt.localeCompare(a.pinnedAt));
|
||||
}
|
||||
async isPinned(ownerId: string, issueId: string) {
|
||||
return this.rows.some(
|
||||
(p) => p.ownerId === ownerId && p.issueId === issueId,
|
||||
);
|
||||
}
|
||||
async countByOwner(ownerId: string) {
|
||||
return this.rows.filter((p) => p.ownerId === ownerId).length;
|
||||
}
|
||||
async pin(pin: PinnedIssueReadModel) {
|
||||
if (!(await this.isPinned(pin.ownerId, pin.issueId))) this.rows.push(pin);
|
||||
}
|
||||
async unpin(ownerId: string, issueId: string) {
|
||||
this.rows = this.rows.filter(
|
||||
(p) => !(p.ownerId === ownerId && p.issueId === issueId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** What each person can read: `ana` all but `hidden`, in the trash `old`. */
|
||||
const issues = new Map<string, IssueReadModel>();
|
||||
const readable = (viewerId: string | null, id: string) =>
|
||||
viewerId === 'ana' && id !== 'hidden';
|
||||
|
||||
@QueryHandler(GetIssuesByIdsQuery)
|
||||
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
|
||||
async execute(query: GetIssuesByIdsQuery) {
|
||||
return query.ids
|
||||
.filter((id) => readable(query.viewerId, id))
|
||||
.map(
|
||||
(id) =>
|
||||
issues.get(id) ??
|
||||
({
|
||||
id,
|
||||
trashedAt: id === 'old' ? '2026-01-01' : null,
|
||||
} as IssueReadModel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const rows = new InMemoryPins();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...PINS,
|
||||
FakeIssuesByIds,
|
||||
{ provide: PinsRepository, useValue: rows },
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
return {
|
||||
rows,
|
||||
commands: moduleRef.get(CommandBus),
|
||||
queries: moduleRef.get(QueryBus),
|
||||
};
|
||||
}
|
||||
|
||||
describe('pinned tickets', () => {
|
||||
it('pins, lists most recent first and unpins', async () => {
|
||||
const { commands, queries } = await setup();
|
||||
await commands.execute(new PinIssueCommand('ana', 'a', true));
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
await commands.execute(new PinIssueCommand('ana', 'b', true));
|
||||
// Twice is still once.
|
||||
await commands.execute(new PinIssueCommand('ana', 'b', true));
|
||||
const mine = await queries.execute(new GetMyPinsQuery('ana'));
|
||||
expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['b', 'a']);
|
||||
expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe(
|
||||
true,
|
||||
);
|
||||
await commands.execute(new PinIssueCommand('ana', 'a', false));
|
||||
expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pin what I cannot read, nor what is in the trash', async () => {
|
||||
const { commands } = await setup();
|
||||
await expect(
|
||||
commands.execute(new PinIssueCommand('ana', 'hidden', true)),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
await expect(
|
||||
commands.execute(new PinIssueCommand('ana', 'old', true)),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it('keeps a pin to a trashed ticket but does not list it', async () => {
|
||||
const { commands, queries, rows } = await setup();
|
||||
await commands.execute(new PinIssueCommand('ana', 'a', true));
|
||||
await rows.pin({ ownerId: 'ana', issueId: 'old', pinnedAt: '2026-01-02' });
|
||||
const mine = await queries.execute(new GetMyPinsQuery('ana'));
|
||||
expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['a']);
|
||||
expect(rows.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it(`stops at ${MAX_PINS}, after forgetting the ones out of reach`, async () => {
|
||||
const { commands, rows } = await setup();
|
||||
for (let i = 0; i < MAX_PINS - 1; i++)
|
||||
await rows.pin({ ownerId: 'ana', issueId: `t${i}`, pinnedAt: `${i}` });
|
||||
await rows.pin({ ownerId: 'ana', issueId: 'hidden', pinnedAt: 'x' });
|
||||
// Full, but one is out of reach: it makes room.
|
||||
await commands.execute(new PinIssueCommand('ana', 'new', true));
|
||||
expect(await rows.isPinned('ana', 'hidden')).toBe(false);
|
||||
expect(await rows.countByOwner('ana')).toBe(MAX_PINS);
|
||||
await expect(
|
||||
commands.execute(new PinIssueCommand('ana', 'one-more', true)),
|
||||
).rejects.toBeInstanceOf(BusinessRuleError);
|
||||
});
|
||||
});
|
||||
150
libs/api/tracker/application/src/lib/saved-filters/pins.ts
Normal file
150
libs/api/tracker/application/src/lib/saved-filters/pins.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryBus,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { PinsRepository } from '../ports/pins.repository';
|
||||
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||
|
||||
/*
|
||||
* Pinned tickets: the few someone keeps at hand, on their home page and in
|
||||
* the search palette. Private, across projects, no history (like the views
|
||||
* of «my work»).
|
||||
*/
|
||||
|
||||
/** A person keeps at most this many. */
|
||||
export const MAX_PINS = 20;
|
||||
|
||||
export class PinIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly issueId: string,
|
||||
/** False: unpin. */
|
||||
public readonly pinned: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** My pinned tickets that I can still read and are not in the trash. */
|
||||
export class GetMyPinsQuery extends Query<IssueReadModel[]> {
|
||||
constructor(public readonly ownerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class IsIssuePinnedQuery extends Query<boolean> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(PinIssueCommand)
|
||||
export class PinIssueCommandHandler
|
||||
implements ICommandHandler<PinIssueCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly pins: PinsRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
async execute(command: PinIssueCommand): Promise<void> {
|
||||
const { ownerId, issueId } = command;
|
||||
if (!command.pinned) return this.pins.unpin(ownerId, issueId);
|
||||
const [issue] = await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(ownerId, [issueId]),
|
||||
);
|
||||
// A ticket I cannot read does not exist, as far as I know.
|
||||
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
||||
if (await this.pins.isPinned(ownerId, issueId)) return;
|
||||
if (
|
||||
(await this.pins.countByOwner(ownerId)) >= MAX_PINS &&
|
||||
(await this.dropUnreadable(ownerId)) >= MAX_PINS
|
||||
)
|
||||
throw new BusinessRuleError(
|
||||
`You already keep ${MAX_PINS} pinned tickets; unpin one first`,
|
||||
);
|
||||
await this.pins.pin({
|
||||
ownerId,
|
||||
issueId,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the pins of tickets I can no longer read (I left the project,
|
||||
* it was deleted) so they do not hold a place; returns how many remain.
|
||||
*/
|
||||
private async dropUnreadable(ownerId: string): Promise<number> {
|
||||
const pins = await this.pins.findByOwner(ownerId);
|
||||
const readable = new Set(
|
||||
(
|
||||
await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(
|
||||
ownerId,
|
||||
pins.map((p) => p.issueId),
|
||||
),
|
||||
)
|
||||
).map((issue) => issue.id),
|
||||
);
|
||||
for (const pin of pins)
|
||||
if (!readable.has(pin.issueId))
|
||||
await this.pins.unpin(ownerId, pin.issueId);
|
||||
return readable.size;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyPinsQuery)
|
||||
export class GetMyPinsQueryHandler
|
||||
implements IQueryHandler<GetMyPinsQuery, IssueReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly pins: PinsRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMyPinsQuery): Promise<IssueReadModel[]> {
|
||||
const pins = await this.pins.findByOwner(query.ownerId);
|
||||
if (pins.length === 0) return [];
|
||||
const issues: IssueReadModel[] = await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(
|
||||
query.ownerId,
|
||||
pins.map((p) => p.issueId),
|
||||
),
|
||||
);
|
||||
// In the trash or out of reach: kept, in case it comes back.
|
||||
const byId = new Map(
|
||||
issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
|
||||
);
|
||||
return pins.flatMap((p) => byId.get(p.issueId) ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(IsIssuePinnedQuery)
|
||||
export class IsIssuePinnedQueryHandler
|
||||
implements IQueryHandler<IsIssuePinnedQuery, boolean>
|
||||
{
|
||||
constructor(private readonly pins: PinsRepository) {}
|
||||
|
||||
execute(query: IsIssuePinnedQuery): Promise<boolean> {
|
||||
return this.pins.isPinned(query.ownerId, query.issueId);
|
||||
}
|
||||
}
|
||||
|
||||
export const PINS = [
|
||||
PinIssueCommandHandler,
|
||||
GetMyPinsQueryHandler,
|
||||
IsIssuePinnedQueryHandler,
|
||||
];
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
|
||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { PINS } from './saved-filters/pins';
|
||||
import { PinsService } from './pins.service';
|
||||
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
||||
import { SavedFiltersService } from './saved-filters.service';
|
||||
import { SprintsService } from './sprints.service';
|
||||
@@ -78,6 +80,7 @@ const SERVICES = [
|
||||
BlockersService,
|
||||
NotificationsService,
|
||||
SavedFiltersService,
|
||||
PinsService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
WebhooksService,
|
||||
@@ -108,6 +111,7 @@ const SERVICES = [
|
||||
...NOTIFICATIONS,
|
||||
...SAVED_FILTERS,
|
||||
...MY_VIEWS,
|
||||
...PINS,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
|
||||
Reference in New Issue
Block a user