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