Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
873 changes: 521 additions & 352 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

70 changes: 62 additions & 8 deletions foundations/server/packages/elastic/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,17 +314,71 @@ class ElasticAdapter implements FullTextAdapter {
from: number | undefined
): Promise<IndexedDoc[]> {
if (query.$search === undefined) return []
const raw = String(query.$search)
// Route field-targeted queries (e.g. `searchTitle:value`, `identifier:HULY-`,
// `comments.message:foo`) through `query_string` so ES can parse the
// field-targeted clauses and apply per-field boosts. Restrict the
// detector to the set of fields we actually index — typing a bare colon
// such as `POC: design review` or a URL must NOT silently route to
// `query_string` (which would throw a parsing exception and surface as
// zero hits). Anything else falls back to `simple_query_string` for full
// backwards compatibility.
// KEEP IN SYNC with packages/ui SearchInputAdvanced.encoder.ts
// ES_NATIVE_FIELDS (and vice versa). The client only emits a `field:value`
// clause for a field it believes the server recognises; if the two lists
// drift, a client-routed clause hits a field the adapter does not treat as
// query_string and silently fails to parse.
//
// SECURITY: `query_string` lets a raw `field:value` clause target
// ANY indexed field (e.g. `space:<id>`, `modifiedBy:<id>`, `attachedTo:<id>`),
// not just the `fields` whitelist below (that list only picks the DEFAULT
// fields for bare terms). This is intentionally NOT gated here because the
// hits this method returns are never authoritative results — they are only a
// candidate id-set. The two enclosing guards make field-targeting safe:
// 1. `workspaceId` is a hard `bool.must` term (see the request below), so
// no clause can reach another workspace's documents.
// 2. Within the workspace, every consumer re-filters by space ACL. The
// only caller is pods/fulltext `/api/v1/search` → FullTextMiddleware,
// which sits BELOW SpaceSecurityMiddleware in the pipeline. That
// middleware runs `provideFindAll({ _id: { $in: <these ids> }, ...q })`
// where `q` still carries the space constraint SpaceSecurityMiddleware
// injected upstream (or, in its >85%-allowed fast path, post-filters
// the result via `clientFilterSpaces`). A forbidden-space doc surfaced
// by a crafted `space:` clause is therefore dropped at the DB findAll /
// result filter and never reaches the client — no content or existence
// oracle. Checked against the server-pipeline middleware order.
//
const KNOWN_FIELD_RE =
/(^|\s)(searchTitle|searchShortTitle|identifier|description\.plain|comments\.message|fulltextSummary)\s*:/i
const usesQueryString = KNOWN_FIELD_RE.test(raw)
const queryBlock: any = usesQueryString
? {
query_string: {
query: raw,
fields: [
'searchTitle^3',
'searchShortTitle^2',
'identifier^2',
'description.plain',
'comments.message^0.7',
'fulltextSummary'
],
default_operator: 'AND',
allow_leading_wildcard: false
}
}
: {
simple_query_string: {
query: raw,
analyze_wildcard: true,
flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE',
default_operator: 'and'
}
}
const request: any = {
bool: {
must: [
{
simple_query_string: {
query: query.$search,
analyze_wildcard: true,
flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE',
default_operator: 'and'
}
},
queryBlock,
{
term: {
workspaceId
Expand Down
8 changes: 8 additions & 0 deletions models/server-tracker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export function createModel (builder: Builder): void {
}
})

builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverTracker.trigger.OnDependencyShiftRequest,
txMatch: {
_class: core.class.TxCreateDoc,
objectClass: tracker.class.DependencyShiftRequest
}
})

builder.mixin(
tracker.ids.AssigneeNotification,
notification.class.NotificationType,
Expand Down
24 changes: 23 additions & 1 deletion models/tracker/src/__tests__/migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
// you may not use this file except in compliance with the License.
//

import { DOMAIN_TX } from '@hcengineering/core'
import { DOMAIN_TASK } from '@hcengineering/model-task'
import tracker from '@hcengineering/tracker'

import { DOMAIN_TRACKER } from '../types'
import { migrateAddStartDate } from '../migration'
import { migrateAddStartDate, migrateRelationActivityAttachment } from '../migration'

describe('migrateAddStartDate', () => {
it('sets startDate=null on every Issue lacking the field (DOMAIN_TASK)', async () => {
Expand Down Expand Up @@ -47,3 +48,24 @@ describe('migrateAddStartDate', () => {
expect(update).toHaveBeenCalledTimes(2)
})
})

describe('migrateRelationActivityAttachment', () => {
it('looks up the relation create-tx in DOMAIN_TX (not DOMAIN_MODEL_TX)', async () => {
const findDomains: string[] = []
const client: any = {
find: jest.fn(async (domain: string, q: any) => {
findDomains.push(domain)
// one broken remove-DUM on the first (DOMAIN_ACTIVITY) call:
if (domain === 'activity') {
return [{ _id: 'dum1', objectId: 'rel1', attachedToClass: 'x', updateCollection: undefined }]
}
return [] // tx lookup returns empty regardless of domain
}),
update: jest.fn(async () => undefined)
}
await migrateRelationActivityAttachment(client)
// The create-tx lookup MUST hit DOMAIN_TX, never DOMAIN_MODEL_TX.
expect(findDomains).toContain(DOMAIN_TX)
expect(findDomains).not.toContain('model_tx')
})
})
67 changes: 65 additions & 2 deletions models/tracker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import {
DOMAIN_TRACKER,
TClassicProjectTypeData,
TComponent,
TDependencyShiftedNotification,
TDependencyShiftRequest,
TIssue,
TIssueRelation,
TIssueStatus,
Expand Down Expand Up @@ -161,6 +163,34 @@ function defineNotifications (builder: Builder): void {
tracker.ids.AssigneeNotification
)

// Notification on Dependency-Shift.
// The cascade bundle is created client-side as a CommonInboxNotification
// subclass (see `dependency-shift-send.ts`); this NotificationType wires it
// into the user's tracker notification group so settings/Inbox provider
// routing work the same as the assignee type. `field` is bound to the
// `cascadeToken` attribute so the auto-`dueDate` generated type stays
// distinct (and they don't compete on the same `dueDate` notify channel).
builder.createDoc(
notification.class.NotificationType,
core.space.Model,
{
hidden: false,
generated: false,
label: tracker.string.DependencyShifted,
group: tracker.ids.TrackerNotificationGroup,
field: 'cascadeToken',
txClasses: [core.class.TxCreateDoc],
objectClass: tracker.class.DependencyShiftedNotification,
templates: {
textTemplate: '{sender} shifted {trigger} — {count} dependent issues moved',
htmlTemplate: '<p>{sender} shifted {trigger} — {count} dependent issues moved</p>',
subjectTemplate: 'Dependency shift'
},
defaultEnabled: true
},
tracker.ids.DependencyShiftedNotification
)

generateClassNotificationTypes(
builder,
tracker.class.Issue,
Expand Down Expand Up @@ -196,7 +226,9 @@ function defineFilters (builder: Builder): void {
key: 'milestone',
component: view.component.ObjectFilter,
showNested: false
}
},
'startDate',
'dueDate'
],
ignoreKeys: ['number', 'estimation', 'attachedTo'],
getVisibleFilters: tracker.function.GetVisibleFilters
Expand Down Expand Up @@ -453,7 +485,9 @@ export function createModel (builder: Builder): void {
TRelatedIssueTarget,
TTypeEstimation,
TTypeRemainingTime,
TProjectTargetPreference
TProjectTargetPreference,
TDependencyShiftedNotification,
TDependencyShiftRequest
)

builder.mixin(tracker.class.Project, core.class.Class, activity.mixin.ActivityDoc, {})
Expand Down Expand Up @@ -596,6 +630,35 @@ export function createModel (builder: Builder): void {
tracker.ids.IssueRemovedActivityViewlet
)

// Activity-Log Remove-Detail Fix.
// Three symmetric viewlets so IssueRelation add/remove/update show up in
// the issue activity feed with a kind+lag+target.title snapshot instead
// of the previous empty "removed related to:" row. The
// RelationActivityPresenter reuses IssueRelationPresenter, which is
// already registered as the ObjectPresenter for tracker.class.IssueRelation
// (models/tracker/src/presenters.ts).
builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, {
objectClass: tracker.class.IssueRelation,
action: 'create',
icon: tracker.icon.Issue,
label: tracker.string.AddedRelation,
component: tracker.component.RelationActivityPresenter
})
builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, {
objectClass: tracker.class.IssueRelation,
action: 'remove',
icon: tracker.icon.Issue,
label: tracker.string.RemovedRelation,
component: tracker.component.RelationActivityPresenter
})
builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, {
objectClass: tracker.class.IssueRelation,
action: 'update',
icon: tracker.icon.Issue,
label: tracker.string.UpdatedRelation,
component: tracker.component.RelationActivityPresenter
})

builder.createDoc(
activity.class.DocUpdateMessageViewlet,
core.space.Model,
Expand Down
Loading