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:
2026-10-17 10:15:28 +02:00
parent 423f82c1a3
commit dfa42a8a1a
32 changed files with 1612 additions and 46 deletions

View File

@@ -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';

View 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));
}
}

View File

@@ -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>;
}

View 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);
});
});

View 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,
];

View File

@@ -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,

View File

@@ -56,3 +56,10 @@ export class MyViewReadModel {
criteria!: MyViewCriteriaReadModel;
createdAt!: string;
}
/** A ticket someone keeps at hand: private to them, no history. */
export class PinnedIssueReadModel {
ownerId!: string;
issueId!: string;
pinnedAt!: string;
}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { PinsRepository } from '@task-stream/api/tracker/application';
import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { PinnedIssueDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoPinsRepository extends PinsRepository {
constructor(
@InjectModel(ReadModels.PinnedIssue)
private readonly pins: Model<PinnedIssueDoc>,
) {
super();
}
async findByOwner(ownerId: string): Promise<PinnedIssueReadModel[]> {
const docs = await this.pins
.find({ ownerId })
.sort({ pinnedAt: -1 })
.lean<PinnedIssueDoc[]>();
return docs.map(({ ownerId, issueId, pinnedAt }) => ({
ownerId,
issueId,
pinnedAt,
}));
}
async isPinned(ownerId: string, issueId: string): Promise<boolean> {
return !!(await this.pins.exists({ ownerId, issueId }));
}
countByOwner(ownerId: string): Promise<number> {
return this.pins.countDocuments({ ownerId });
}
async pin(pin: PinnedIssueReadModel): Promise<void> {
await this.pins.updateOne(
{ ownerId: pin.ownerId, issueId: pin.issueId },
{ $setOnInsert: pin },
{ upsert: true },
);
}
async unpin(ownerId: string, issueId: string): Promise<void> {
await this.pins.deleteOne({ ownerId, issueId });
}
}

View File

@@ -13,6 +13,7 @@ import {
ReleaseReadModel,
SavedFilterReadModel,
MyViewReadModel,
PinnedIssueReadModel,
SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
@@ -42,6 +43,7 @@ export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export type MyViewDoc = Doc<MyViewReadModel>;
export type PinnedIssueDoc = PinnedIssueReadModel & { _id?: Types.ObjectId };
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
export type AutomationDoc = Doc<Automation>;
export interface AutomationRunDoc {
@@ -104,6 +106,7 @@ export const ReadModels = {
NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
MyView: 'MyView',
PinnedIssue: 'PinnedIssue',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook',
@@ -322,6 +325,16 @@ class MyViewDocument {
}
export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument);
@Schema({ ...LOOSE, collection: 'pinned_issues' })
class PinnedIssueDocument {
@Prop({ type: String }) ownerId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) pinnedAt!: string;
}
export const PinnedIssueSchema =
SchemaFactory.createForClass(PinnedIssueDocument);
PinnedIssueSchema.index({ ownerId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@@ -424,6 +437,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.MyView, schema: MyViewSchema },
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -13,6 +13,7 @@ import {
PushSender,
SavedFiltersRepository,
MyViewsRepository,
PinsRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
AutomationsRepository,
@@ -78,6 +79,7 @@ import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.rep
import { WebPushSender } from './push/web-push.sender';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -154,6 +156,7 @@ const PORTS = [
{ provide: PushSender, useClass: WebPushSender },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{ provide: MyViewsRepository, useClass: MongoMyViewsRepository },
{ provide: PinsRepository, useClass: MongoPinsRepository },
{
provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository,

View File

@@ -17,6 +17,7 @@ import {
BlockersService,
CommentsService,
IssuesService,
PinsService,
PresenceService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
@@ -60,6 +61,7 @@ export class IssueResolver {
private readonly workflows: WorkflowsService,
private readonly blockers: BlockersService,
private readonly presence: PresenceService,
private readonly pins: PinsService,
) {}
@Mutation(() => [PresenceGql], {
@@ -245,6 +247,16 @@ export class IssueResolver {
return !!me && issue.voterIds.includes(me.id);
}
@ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)',
})
pinned(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): Promise<boolean> | boolean {
return !!me && this.pins.isPinned(me.id, issue.id);
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
linkedIssues(
@CurrentUser() me: User | undefined,
@@ -317,6 +329,35 @@ export class IssueResolver {
return this.issuesService.watch(me.id, issueId, true);
}
@Query(() => [IssueGql], {
description:
'My pinned tickets, most recently pinned first (trashed or out of reach left out)',
})
myPins(@CurrentUser() me: User) {
return this.pins.mine(me.id);
}
@Mutation(() => Boolean, {
description:
'Pin a ticket: keep it at hand on my home page and in search (private, at most 20)',
})
async pinIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.pins.pin(me.id, issueId, true);
return true;
}
@Mutation(() => Boolean, { description: 'Unpin a ticket' })
async unpinIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.pins.pin(me.id, issueId, false);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue(
@CurrentUser() me: User,

View File

@@ -32,6 +32,7 @@ import {
import {
CommentsService,
IssuesService,
PinsService,
PresenceService,
ProjectsService,
} from '@task-stream/api/tracker/application';
@@ -94,6 +95,7 @@ export class IssuesController {
private readonly refs: Refs,
private readonly presence: PresenceService,
private readonly projects: ProjectsService,
private readonly pins: PinsService,
) {}
@Get('me/home')
@@ -163,6 +165,41 @@ export class IssuesController {
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
}
@Get('me/pins')
@ApiOperation({
summary: 'My pinned tickets, most recently pinned first',
description:
'Across my projects; tickets in the trash or out of my reach are left out.',
})
@ApiOkResponse({ type: [IssueDto] })
@ApiReadErrors()
async myPins(@CurrentUser() me: User) {
return this.refs.issueDtos(await this.pins.mine(me.id), viewerOf(me));
}
@Put('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Pin a ticket: keep it at hand on my home page and in search',
description: 'Private to me; at most 20. Pinning it again changes nothing.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async pin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), true);
}
@Delete('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Unpin a ticket' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unpin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({