From bba2fe8c54cc6d727c7ff9886e7a88c73a7dc632 Mon Sep 17 00:00:00 2001 From: buddh4 Date: Mon, 29 Jul 2024 20:37:30 +0200 Subject: [PATCH 01/32] docs: Fixed get started link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39df1aa4c..db1e6ed31 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ platforms. ## Getting Started -To get started with Lyvely, follow the installation instructions in our [documentation](https://docs.lyvely.app). +To get started with Lyvely, follow the installation instructions in our [documentation](https://www.lyvelyjs.com/docs/admin/intro/installation). ## Core Features From 233dcf1165f49ce842512f3731aa2929d2144a75 Mon Sep 17 00:00:00 2001 From: buddh4 Date: Mon, 12 Aug 2024 18:34:43 +0200 Subject: [PATCH 02/32] feat(analytics): Implemented time-series chart type --- .../rush/browser-approved-packages.json | 4 +- .../rush/nonbrowser-approved-packages.json | 2 +- common/config/rush/pnpm-lock.yaml | 23 +++- .../content/controllers/content.controller.ts | 16 ++- .../src/content/daos/content-query.builder.ts | 121 ++++++++++++++++-- .../daos/content-search-filter.interface.ts | 26 ++++ .../api/src/content/daos/content-type.dao.ts | 3 +- .../api/src/content/daos/content.dao.spec.ts | 38 +++++- .../core/api/src/content/daos/content.dao.ts | 14 ++ .../services/content-stream.service.ts | 43 ++----- .../src/content/services/content.service.ts | 6 +- packages/core/api/src/server/lyvely.server.ts | 1 - .../src/content/endpoints/content.client.ts | 15 ++- .../src/content/endpoints/content.endpoint.ts | 9 +- .../content/endpoints/content.repository.ts | 10 +- .../interfaces/content-picker.interface.ts | 3 + .../content-search-result.interface.ts | 5 + .../interface/src/content/interfaces/index.ts | 2 + .../models/content-search-result.model.ts | 13 ++ ...r.spec.ts => content.filter.model.spec.ts} | 0 ...tent.filter.ts => content.filter.model.ts} | 0 .../interface/src/content/models/index.ts | 3 +- packages/core/web/locales/en-us.json | 1 + .../src/content/components/ContentDetails.vue | 7 + .../src/content/components/ContentPicker.vue | 55 ++++++++ .../core/web/src/content/components/index.ts | 2 + .../web/src/tags/components/TagPicker.vue | 5 +- .../analytics/api/src/aggregations/index.ts | 1 + .../src/aggregations/interval.aggregation.ts | 33 ++++- packages/features/analytics/api/src/index.ts | 2 + .../registries/chart-category-api.registry.ts | 6 +- .../schemas/chart-series-schema.factory.ts | 26 ++++ .../analytics/api/src/schemas/index.ts | 1 + ...rofile-score-chart-series-config.schema.ts | 13 +- .../user-score-chart-series-config.schema.ts | 6 +- .../profile-score-aggregation.service.ts | 8 +- .../src/interfaces/chart.interface.ts | 2 +- .../src/models/profile-score.series.ts | 4 +- .../src/registries/chart-category.registry.ts | 19 --- .../interface/src/registries/index.ts | 1 - packages/features/analytics/web/src/index.ts | 4 + .../registries/chart-category-web.registry.ts | 6 +- packages/features/habits/api/package.json | 1 + .../features/habits/api/src/habits.events.ts | 10 ++ .../features/habits/api/src/habits.module.ts | 4 + .../habit-value-chart-series-config.schema.ts | 18 +++ .../features/habits/api/src/schemas/index.ts | 1 + .../src/services/habit-data-point.service.ts | 28 +++- .../habit-value-aggregation.service.ts | 9 ++ .../features/habits/api/src/services/index.ts | 1 + .../features/habits/interface/package.json | 1 + .../src/charts/habit-value.series.ts | 17 +++ .../habits/interface/src/charts/index.ts | 1 + .../features/habits/interface/src/index.ts | 1 + .../src/components/TimeSeriesChartForm.vue | 28 ++++ .../habits/web/src/locales/base.en-us.json | 6 + packages/features/habits/web/src/module.ts | 10 ++ .../habits/web/src/views/HabitsView.vue | 2 + packages/libs/time-series/api/package.json | 1 + .../libs/time-series/api/src/schemas/index.ts | 1 + ...series-value-chart-series-config.schema.ts | 26 ++++ .../time-series/api/src/services/index.ts | 1 + .../time-series-value-aggregation.service.ts | 45 +++++++ .../libs/time-series/interface/package.json | 1 + .../time-series/interface/src/charts/index.ts | 1 + .../time-series-value-chart-config.model.ts | 24 ++++ .../libs/time-series/interface/src/index.ts | 1 + packages/libs/time-series/web/package.json | 1 + .../src/components/TimeSeriesChartForm.vue | 31 +++++ .../time-series/web/src/components/index.ts | 3 + 70 files changed, 716 insertions(+), 117 deletions(-) create mode 100644 packages/core/interface/src/content/interfaces/content-picker.interface.ts create mode 100644 packages/core/interface/src/content/interfaces/content-search-result.interface.ts create mode 100644 packages/core/interface/src/content/models/content-search-result.model.ts rename packages/core/interface/src/content/models/{content.filter.spec.ts => content.filter.model.spec.ts} (100%) rename packages/core/interface/src/content/models/{content.filter.ts => content.filter.model.ts} (100%) create mode 100644 packages/core/web/src/content/components/ContentPicker.vue create mode 100644 packages/features/analytics/api/src/schemas/chart-series-schema.factory.ts delete mode 100644 packages/features/analytics/interface/src/registries/chart-category.registry.ts create mode 100644 packages/features/habits/api/src/habits.events.ts create mode 100644 packages/features/habits/api/src/schemas/habit-value-chart-series-config.schema.ts create mode 100644 packages/features/habits/api/src/services/habit-value-aggregation.service.ts create mode 100644 packages/features/habits/interface/src/charts/habit-value.series.ts create mode 100644 packages/features/habits/interface/src/charts/index.ts create mode 100644 packages/features/habits/web/src/components/TimeSeriesChartForm.vue create mode 100644 packages/libs/time-series/api/src/schemas/time-series-value-chart-series-config.schema.ts create mode 100644 packages/libs/time-series/api/src/services/time-series-value-aggregation.service.ts create mode 100644 packages/libs/time-series/interface/src/charts/index.ts create mode 100644 packages/libs/time-series/interface/src/charts/time-series-value-chart-config.model.ts create mode 100644 packages/libs/time-series/web/src/components/TimeSeriesChartForm.vue diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index f63eb6829..97265dab1 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -48,11 +48,11 @@ }, { "name": "@lyvely/analytics-interface", - "allowedCategories": [ "feature" ] + "allowedCategories": [ "feature", "lib" ] }, { "name": "@lyvely/analytics-web", - "allowedCategories": [ "application", "feature" ] + "allowedCategories": [ "application", "feature", "lib" ] }, { "name": "@lyvely/calendar-plan-interface", diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index 85b4df082..3877430dc 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -4,7 +4,7 @@ "packages": [ { "name": "@lyvely/analytics", - "allowedCategories": [ "application", "feature" ] + "allowedCategories": [ "application", "feature", "lib" ] }, { "name": "@lyvely/api", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 784625a47..7ce8a8234 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1201,6 +1201,9 @@ importers: '@nestjs/common': specifier: ^10.3.8 version: 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/event-emitter': + specifier: ^2.0 + version: 2.0.4(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@nestjs/mongoose': specifier: ^10.0.6 version: 10.0.6(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1))(mongoose@8.4.4(@aws-sdk/credential-providers@3.600.0)(socks@2.8.3))(rxjs@7.8.1) @@ -1271,6 +1274,9 @@ importers: '@buddh4/mapped-types': specifier: ^1.2.1 version: 1.2.1 + '@lyvely/analytics-interface': + specifier: workspace:* + version: link:../../analytics/interface '@lyvely/calendar-plan-interface': specifier: workspace:* version: link:../../../libs/calendar-plan/interface @@ -3181,6 +3187,9 @@ importers: ../../packages/libs/time-series/api: dependencies: + '@lyvely/analytics': + specifier: workspace:* + version: link:../../../features/analytics/api '@lyvely/api': specifier: workspace:* version: link:../../../core/api @@ -3257,6 +3266,9 @@ importers: ../../packages/libs/time-series/interface: dependencies: + '@lyvely/analytics-interface': + specifier: workspace:* + version: link:../../../features/analytics/interface '@lyvely/calendar-plan-interface': specifier: workspace:* version: link:../../calendar-plan/interface @@ -3351,6 +3363,9 @@ importers: ../../packages/libs/time-series/web: dependencies: + '@lyvely/analytics-web': + specifier: workspace:* + version: link:../../../features/analytics/web '@lyvely/calendar-plan-web': specifier: workspace:* version: link:../../calendar-plan/web @@ -14880,8 +14895,8 @@ packages: vue-component-type-helpers@2.0.22: resolution: {integrity: sha512-gPr2Ba7efUwy/Vfbuf735bHSVdN4ycoZUCHfypkI33M9DUH+ieRblLLVM2eImccFYaWNWwEzURx02EgoXDBmaQ==} - vue-component-type-helpers@2.0.24: - resolution: {integrity: sha512-Jr5N8QVYEcbQuMN1LRgvg61758G8HTnzUlQsAFOxx6Y6X8kmhJ7C+jOvWsQruYxi3uHhhS6BghyRlyiwO99DBg==} + vue-component-type-helpers@2.0.26: + resolution: {integrity: sha512-sO9qQ8oC520SW6kqlls0iqDak53gsTVSrYylajgjmkt1c0vcgjsGSy1KzlDrbEx8pm02IEYhlUkU5hCYf8rwtg==} vue-demi@0.14.8: resolution: {integrity: sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==} @@ -20230,7 +20245,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.4.31(typescript@5.5.2) - vue-component-type-helpers: 2.0.24 + vue-component-type-helpers: 2.0.26 transitivePeerDependencies: - encoding - prettier @@ -30053,7 +30068,7 @@ snapshots: vue-component-type-helpers@2.0.22: {} - vue-component-type-helpers@2.0.24: {} + vue-component-type-helpers@2.0.26: {} vue-demi@0.14.8(vue@3.4.31(typescript@5.5.2)): dependencies: diff --git a/packages/core/api/src/content/controllers/content.controller.ts b/packages/core/api/src/content/controllers/content.controller.ts index 486d69abd..6cb6c2fda 100644 --- a/packages/core/api/src/content/controllers/content.controller.ts +++ b/packages/core/api/src/content/controllers/content.controller.ts @@ -4,19 +4,33 @@ import { SetMilestoneModel, ContentEndpoints, UpdateTaskListItemModel, + ContentFilter, + ContentSearchResult, } from '@lyvely/interface'; -import { Post, HttpCode, HttpStatus, Param, Request, Put } from '@nestjs/common'; +import { Post, HttpCode, HttpStatus, Param, Request, Put, Get } from '@nestjs/common'; import { Policies } from '@/policies'; import { ContentService } from '../services'; import { ContentDeletePolicy, ContentWritePolicy } from '../policies'; import { ProtectedProfileContentRequest } from '../types'; import { ContentTypeController } from '../decorators'; import { ValidBody } from '@/core'; +import type { ProfileRequest } from '@/profiles'; +import { IContentSearchFilter } from '@/content/daos'; @ContentTypeController(API_CONTENT) export class ContentController implements ContentEndpoint { constructor(private contentService: ContentService) {} + @Get(ContentEndpoints.SEARCH()) + async search( + @ValidBody() filter: IContentSearchFilter, + @Request() req: ProfileRequest + ): Promise { + const { context } = req; + const content = await this.contentService.search(context, filter); + return new ContentSearchResult({ result: content.map((c) => c.toModel(context.user)) }); + } + @Post(ContentEndpoints.ARCHIVE(':cid')) @HttpCode(HttpStatus.NO_CONTENT) @Policies(ContentDeletePolicy) diff --git a/packages/core/api/src/content/daos/content-query.builder.ts b/packages/core/api/src/content/daos/content-query.builder.ts index c80b95f6e..4f015de09 100644 --- a/packages/core/api/src/content/daos/content-query.builder.ts +++ b/packages/core/api/src/content/daos/content-query.builder.ts @@ -3,6 +3,8 @@ import { Content } from '../schemas/content.schema'; import { ProfileRoleLevel } from '@lyvely/interface'; import { IContentSearchFilter } from './content-search-filter.interface'; import { isNotNil } from '@lyvely/common'; +import { Profile, Tag } from '@/profiles'; +import { isMongoId } from 'class-validator'; export class ContentCondition { /** @@ -18,7 +20,22 @@ export class ContentCondition { * * @type {FilterQuery} */ - static NOT_ARCHIVED: FilterQuery = { 'meta.archived': { $in: [null, false] } }; + static NOT_ARCHIVED: FilterQuery = { 'meta.archived': { $ne: true } }; + + /** + * Represents a filter query for retrieving deleted content. + * + * @type {FilterQuery} + * @constant + */ + static DELETED: FilterQuery = { 'meta.deleted': true }; + + /** + * Represents a filter query for non-deleted content. + * + * @type {FilterQuery} + */ + static NOT_DELETED: FilterQuery = { 'meta.deleted': { $in: [null, false] } }; /** * Represents a filter to only include content visible by the given profile role level. @@ -32,6 +49,26 @@ export class ContentCondition { 'meta.visibility': { $gte: level }, }); + /** + * Filters the content based on the provided profile id. + * + * @param {DocumentIdentity} pid - The DocumentIdentity of the contents' profile. + * @returns {FilterQuery} - The filter query object with the specified pid. + */ + static pid(pid: DocumentIdentity): FilterQuery { + return { pid: assureObjectId(pid) }; + } + + /** + * Filters the content based on the provided organization id. + * + * @param {DocumentIdentity} oid - The DocumentIdentity of the contents' organization. + * @returns {FilterQuery} - The filter query object with the specified oid. + */ + static oid(oid: DocumentIdentity): FilterQuery { + return { oid: assureObjectId(oid) }; + } + /** * Filters the content based on the provided DocumentIdentity. * @@ -42,6 +79,26 @@ export class ContentCondition { return { _id: assureObjectId(cid) }; } + /** + * Filters the content based on the provided type. + * + * @param {DocumentIdentity} type - The content type. + * @returns {FilterQuery} - The filter query object with the specified type. + */ + static type(type: string): FilterQuery { + return { type }; + } + + /** + * Filters content based on the provided parentId DocumentIdentity. + * + * @param {DocumentIdentity} parentId - The DocumentIdentity of the parent content document. + * @returns {FilterQuery} - The filter query object with the specified 'parentId' filter. + */ + static parentId(parentId: DocumentIdentity): FilterQuery { + return { 'meta.parentId': assureObjectId(parentId) }; + } + /** * Returns a filter query condition based on the given archived flag. * @@ -52,6 +109,27 @@ export class ContentCondition { return archived ? ContentCondition.ARCHIVED : ContentCondition.NOT_ARCHIVED; } + /** + * Returns a filter query condition based on the given deleted flag. + * + * @param {boolean} deleted - Specifies whether to filter by deleted or not deleted content. + * @return {FilterQuery} The filter query condition based on the deleted flag. If deleted is true, it returns the condition for deleted content, otherwise it returns the condition for not deleted content. + */ + static deleted(deleted: boolean): FilterQuery { + return deleted ? ContentCondition.DELETED : ContentCondition.NOT_DELETED; + } + + /** + * Constructs a filter query object based on the provided text query string. + * + * @param {string} query - The query string to perform the search on. + * + * @return {FilterQuery} - The filter query object containing the $text operator with the $search parameter. + */ + static query(query: string): FilterQuery { + return { $text: { $search: query } }; + } + /** * Retrieves a filter query for content visible for the given profile role level. * @@ -81,6 +159,16 @@ export class ContentCondition { static withMilestones(mids: DocumentIdentity[]): FilterQuery { return { 'meta.mid': { $in: mids.map((mid) => assureObjectId(mid)) } }; } + + /** + * Returns a content tag id filter. + * + * @param {DocumentIdentity} tagIds - The parameter containing the necessary data for retrieval. + * @returns {FilterQuery} - A FilterQuery object with tag id condition. + */ + static tagIds(tagIds: DocumentIdentity[]): FilterQuery { + return { tagIds: { $all: tagIds.map((tid) => assureObjectId(tid)) } }; + } } /** @@ -90,17 +178,32 @@ export class ContentCondition { * * @return {FilterQuery | undefined} - The MongoDB query object that can be used for content filtering or undefined if `filter` is empty. */ -export function buildContentFilterQuery< - T extends Content = Content, - TFilter extends IContentSearchFilter = IContentSearchFilter, ->(filter?: TFilter): FilterQuery | undefined { +export function buildContentFilterQuery(filter: undefined): undefined; +export function buildContentFilterQuery( + filter: IContentSearchFilter +): FilterQuery; +export function buildContentFilterQuery( + filter?: IContentSearchFilter +): FilterQuery | undefined { if (!filter) return undefined; + if (isMongoId(filter.query?.trim())) { + filter.cid = filter.query!.trim(); + delete filter.query; + } + const conditions = [ - isNotNil(filter.cid) ? ContentCondition.cid(filter.cid!) : null, - isNotNil(filter.archived) ? ContentCondition.archived(filter.archived!) : null, - isNotNil(filter.roleLevel) ? ContentCondition.visibility(filter.roleLevel!) : null, - ]; + isNotNil(filter.pid) ? ContentCondition.pid(filter.pid) : null, + isNotNil(filter.oid) ? ContentCondition.oid(filter.oid) : null, + isNotNil(filter.cid) ? ContentCondition.cid(filter.cid) : null, + isNotNil(filter.type) ? ContentCondition.type(filter.type) : null, + isNotNil(filter.tagIds) ? ContentCondition.tagIds(filter.tagIds) : null, + isNotNil(filter.parentId) ? ContentCondition.parentId(filter.parentId) : null, + isNotNil(filter.archived) ? ContentCondition.archived(filter.archived) : null, + isNotNil(filter.deleted) ? ContentCondition.deleted(filter.deleted) : null, + isNotNil(filter.query) ? ContentCondition.query(filter.query) : null, + isNotNil(filter.roleLevel) ? ContentCondition.visibility(filter.roleLevel) : null, + ].filter((c) => isNotNil(c)); if (isNotNil(filter.conditions)) { conditions.push(...filter.conditions.filter((c) => isNotNil(c))); diff --git a/packages/core/api/src/content/daos/content-search-filter.interface.ts b/packages/core/api/src/content/daos/content-search-filter.interface.ts index 6ee0f992e..fc3bb46bc 100644 --- a/packages/core/api/src/content/daos/content-search-filter.interface.ts +++ b/packages/core/api/src/content/daos/content-search-filter.interface.ts @@ -1,14 +1,33 @@ import { type DocumentIdentity, FilterQuery } from '@/core'; import { Content } from '@/content/schemas'; import { ProfileRoleLevel } from '@lyvely/interface'; +import { Profile, Tag } from '@/profiles'; /** * Interface representing a content search filter. */ export interface IContentSearchFilter { + /** Search content of specific profile. **/ + pid?: DocumentIdentity; + + /** Search content of specific organization. **/ + oid?: DocumentIdentity; + /** Search for a specific document. **/ cid?: DocumentIdentity; + /** Filter by parent content. **/ + parentId?: DocumentIdentity; + + /** Filter by tags. **/ + tagIds?: DocumentIdentity[]; + + /** Filter by content type. **/ + type?: string; + + /** Filter by text query. **/ + query?: string; + /** * If set to true, the query will only return archived documents. * If set to false, the query will only return unarchived documents. @@ -16,6 +35,13 @@ export interface IContentSearchFilter { */ archived?: boolean; + /** + * If set to true, the query will only return deleted documents. + * If set to false, the query will only return non deleted documents. + * Otherwise, the query will ignore the deleted state of the documents. + */ + deleted?: boolean; + /** Set the maximum profile relation role level. **/ roleLevel?: ProfileRoleLevel; diff --git a/packages/core/api/src/content/daos/content-type.dao.ts b/packages/core/api/src/content/daos/content-type.dao.ts index 4ef2d24d0..5385db658 100644 --- a/packages/core/api/src/content/daos/content-type.dao.ts +++ b/packages/core/api/src/content/daos/content-type.dao.ts @@ -72,7 +72,8 @@ export abstract class ContentTypeDao< * @return {FilterQuery | undefined} - The filter query object or undefined if the filter is not provided. */ protected buildFilterQuery(filter?: TFilter): FilterQuery | undefined { - return buildContentFilterQuery(filter); + if (!filter) return undefined; + return buildContentFilterQuery(filter); } /** diff --git a/packages/core/api/src/content/daos/content.dao.spec.ts b/packages/core/api/src/content/daos/content.dao.spec.ts index c8b6bfe93..526a0d6bd 100644 --- a/packages/core/api/src/content/daos/content.dao.spec.ts +++ b/packages/core/api/src/content/daos/content.dao.spec.ts @@ -1,6 +1,6 @@ import { ILyvelyTestingModule } from '@/testing'; import { ContentDao } from './index'; -import { Content, ContentSchema } from '../schemas'; +import { Content, ContentSchema, ContentMetadata } from '../schemas'; import { buildContentTest, TestContent, TestContentData, TestContentSchema } from '../testing'; import { Model } from '@/core'; import { User } from '@/users'; @@ -38,11 +38,11 @@ describe('content dao', () => { return testingModule.afterEach(); }); - async function createTestContent(user: User, profile: Profile, testData = 'Testing...') { + async function createTestContent(user: User, profile: Profile, testData = 'Testing...', initData: Partial = {}) { const testContent = new TestContent( { profile, user }, - { - content: new TestContentData({ testData: testData }), + { ...{content: new TestContentData({ testData: testData }) }, + ...initData } ); const entity = await testContentModel.create(testContent); @@ -71,4 +71,34 @@ describe('content dao', () => { expect(search.content.testData).toEqual('Hello World'); }); }); + + describe('search', () => { + describe('archived', () => { + it('ignore archived flag', async () => { + const { user, profile } = ProfileTestDataUtils.createDummyUserAndProfile(); + const archivedContent = await createTestContent(user, profile, 'A', { meta: new ContentMetadata({ archived: true }) }); + const nonArchivedContent = await createTestContent(user, profile, 'B'); + const search = await contentDao.search(profile, {}); + expect(search.length).toEqual(2); + }); + + it('search archived content', async () => { + const { user, profile } = ProfileTestDataUtils.createDummyUserAndProfile(); + const archivedContent = await createTestContent(user, profile, 'A', { meta: new ContentMetadata({ archived: true }) }); + const nonArchivedContent = await createTestContent(user, profile, 'B'); + const search = await contentDao.search(profile, { archived: true }); + expect(search.length).toEqual(1); + expect(search[0]._id).toEqual(archivedContent._id); + }); + + it('search non archived content', async () => { + const { user, profile } = ProfileTestDataUtils.createDummyUserAndProfile(); + const archivedContent = await createTestContent(user, profile, 'A', { meta: new ContentMetadata({ archived: true }) }); + const nonArchivedContent = await createTestContent(user, profile, 'B'); + const search = await contentDao.search(profile, { archived: false }); + expect(search.length).toEqual(1); + expect(search[0]._id).toEqual(nonArchivedContent._id); + }); + }) + }); }); diff --git a/packages/core/api/src/content/daos/content.dao.ts b/packages/core/api/src/content/daos/content.dao.ts index dd80b7d0c..1ec00c693 100644 --- a/packages/core/api/src/content/daos/content.dao.ts +++ b/packages/core/api/src/content/daos/content.dao.ts @@ -5,6 +5,8 @@ import { Dao, DocumentIdentity, TObjectId } from '@/core'; import { ContentTypeDao } from './content-type.dao'; import { ProfileShardData } from '@/profiles'; import { TenancyIsolation } from '@/core/tenancy'; +import { buildContentFilterQuery } from './content-query.builder'; +import { type IContentSearchFilter } from './content-search-filter.interface'; /** * A generic content DAO used for common content data access jobs. @@ -15,6 +17,18 @@ export class ContentDao extends ContentTypeDao { @Inject() protected override typeRegistry: ContentTypeRegistry; + /** + * Searches for content based on a given filter. + * + * @param {ProfileShardData} context - The context of the search operation. + * @param {IContentSearchFilter} filter - The filter to be applied to the search. + * + * @return {Promise} - A Promise that resolves with an array of Content objects matching the filter. + */ + async search(context: ProfileShardData, filter: IContentSearchFilter): Promise { + return this.findAll(buildContentFilterQuery(filter)); + } + /** * Increments the child count of a parent document by 1. * diff --git a/packages/core/api/src/content/services/content-stream.service.ts b/packages/core/api/src/content/services/content-stream.service.ts index 3da199a24..f471da4c2 100644 --- a/packages/core/api/src/content/services/content-stream.service.ts +++ b/packages/core/api/src/content/services/content-stream.service.ts @@ -1,10 +1,10 @@ import { AbstractStreamService } from '@/streams'; import { ContentRequestFilter } from '@lyvely/interface'; import { Inject, Injectable, Logger } from '@nestjs/common'; -import { ContentDao } from '../daos'; +import { buildContentFilterQuery, ContentDao } from '../daos'; import { Content } from '../schemas'; import { ProfileContext } from '@/profiles'; -import { FilterQuery, assureObjectId } from '@/core'; +import { FilterQuery } from '@/core'; import { ContentPolicyService } from './content-policy.service'; @Injectable() @@ -21,11 +21,6 @@ export class ContentStreamService extends AbstractStreamService< protected logger = new Logger(ContentStreamService.name); - createQueryFilter(context: ProfileContext, filter?: ContentRequestFilter): FilterQuery { - const query = { pid: context.pid, oid: context.oid } as FilterQuery; - return this.applyFilter(query, filter); - } - protected override async prepareModels( context: ProfileContext, models: Content[] @@ -45,32 +40,14 @@ export class ContentStreamService extends AbstractStreamService< return query; } - applyFilter(query: FilterQuery, filter?: ContentRequestFilter) { - query['meta.parentId'] = filter?.parentId ? assureObjectId(filter.parentId) : null; - - if (!filter) return query; - - if (filter.tagIds?.length) { - query['tagIds'] = { $all: filter.tagIds }; - } - - if (filter.archived) { - query['meta.archived'] = true; - } else { - query['meta.archived'] = { $ne: true }; - } - - if (filter.deleted) { - query['meta.deleted'] = true; - } else { - query['meta.deleted'] = { $ne: true }; - } - - if (filter.query?.length) { - query.$text = { $search: filter.query }; - } - - return query; + createQueryFilter(context: ProfileContext, filter?: ContentRequestFilter): FilterQuery { + return buildContentFilterQuery({ + pid: context.pid, + oid: context.oid, + archived: false, + deleted: false, + ...filter, + }); } protected getSortField(): string { diff --git a/packages/core/api/src/content/services/content.service.ts b/packages/core/api/src/content/services/content.service.ts index 33b9e69a9..4a333293d 100644 --- a/packages/core/api/src/content/services/content.service.ts +++ b/packages/core/api/src/content/services/content.service.ts @@ -27,6 +27,10 @@ export class ContentService { protected contentPolicyService: ContentPolicyService ) {} + async search(context: ProfileContext, filter: IContentSearchFilter) { + return this.contentDao.search(context.profile, filter); + } + /** * Finds a single content document and populates its content policies. When using this function, you either need to * manually validate the required policies or use the `roleLevel` search filter. @@ -38,7 +42,7 @@ export class ContentService { * @private * @throws ForbiddenException */ - public async findByContextAndId( + async findByContextAndId( context: ProfileContext, cid: DocumentIdentity, filter?: IContentSearchFilter, diff --git a/packages/core/api/src/server/lyvely.server.ts b/packages/core/api/src/server/lyvely.server.ts index 9705393b7..a2b5ceee8 100644 --- a/packages/core/api/src/server/lyvely.server.ts +++ b/packages/core/api/src/server/lyvely.server.ts @@ -17,7 +17,6 @@ import https from 'https'; import compression from 'compression'; import { useDayJsDateTimeAdapter } from '@lyvely/dates'; import { ExpressAdapter, NestExpressApplication } from '@nestjs/platform-express'; -import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; import fs from 'fs'; import * as net from 'net'; import { MulterConfigFactory } from '@/files'; diff --git a/packages/core/interface/src/content/endpoints/content.client.ts b/packages/core/interface/src/content/endpoints/content.client.ts index e8aad03fa..116e1b654 100644 --- a/packages/core/interface/src/content/endpoints/content.client.ts +++ b/packages/core/interface/src/content/endpoints/content.client.ts @@ -1,12 +1,23 @@ import { useSingleton } from '@lyvely/common'; import { IContentClient } from './content.endpoint'; import repository from './content.repository'; -import { IProfileApiRequestOptions, unwrapResponse } from '@/endpoints'; -import { ContentModel, UpdateTaskListItemModel } from '../models'; +import { IProfileApiRequestOptions, unwrapAndTransformResponse, unwrapResponse } from '@/endpoints'; +import { + ContentFilter, + ContentModel, + ContentSearchResult, + UpdateTaskListItemModel, +} from '../models'; import { getContentModelType } from '../registries'; import type { PropertiesOf } from '@lyvely/common'; export class ContentClient implements IContentClient { + async search( + filter: ContentFilter, + options?: IProfileApiRequestOptions + ): Promise { + return unwrapAndTransformResponse(repository.search(filter, options), ContentSearchResult); + } async setMilestone(id: string, mid: string, options?: IProfileApiRequestOptions): Promise { return unwrapResponse(repository.setMilestone(id, mid, options)); } diff --git a/packages/core/interface/src/content/endpoints/content.endpoint.ts b/packages/core/interface/src/content/endpoints/content.endpoint.ts index ecfd74a98..656a7caae 100644 --- a/packages/core/interface/src/content/endpoints/content.endpoint.ts +++ b/packages/core/interface/src/content/endpoints/content.endpoint.ts @@ -1,9 +1,15 @@ import { Endpoint, profileApiPrefix } from '@/endpoints'; -import { UpdateTaskListItemModel, ContentModel } from '../models'; +import { + UpdateTaskListItemModel, + ContentModel, + ContentFilter, + ContentSearchResult, +} from '../models'; export const API_CONTENT = profileApiPrefix('content'); export interface IContentClient { + search: (filter: ContentFilter) => Promise; archive: (cid: string) => Promise; restore: (cid: string) => Promise; setMilestone: (cid: string, mid: string) => Promise; @@ -13,6 +19,7 @@ export interface IContentClient { export type ContentEndpoint = Endpoint; export const ContentEndpoints = { + SEARCH: () => `/search`, ARCHIVE: (cid: string) => `${cid}/archive`, RESTORE: (cid: string) => `${cid}/restore`, SET_MILESTONE: (cid: string) => `${cid}/set-milestone`, diff --git a/packages/core/interface/src/content/endpoints/content.repository.ts b/packages/core/interface/src/content/endpoints/content.repository.ts index d3adef9c0..5fff5caae 100644 --- a/packages/core/interface/src/content/endpoints/content.repository.ts +++ b/packages/core/interface/src/content/endpoints/content.repository.ts @@ -1,13 +1,19 @@ -import { SetMilestoneModel, UpdateTaskListItemModel } from '../models'; +import { ContentFilter, SetMilestoneModel, UpdateTaskListItemModel } from '../models'; import { API_CONTENT, ContentEndpoints, IContentClient } from './content.endpoint'; import { useApi } from '@/repository'; import { IProfileApiRequestOptions } from '@/endpoints'; // TODO: https://github.com/microsoft/TypeScript/issues/47663 -import type {} from 'axios'; const api = useApi(API_CONTENT); export default { + search(filter: ContentFilter, options?: IProfileApiRequestOptions) { + return api.get<'search'>(ContentEndpoints.SEARCH(), { + params: filter, + ...options, + }); + }, + setMilestone(cid: string, mid: string, options?: IProfileApiRequestOptions) { return api.post<'setMilestone'>( ContentEndpoints.SET_MILESTONE(cid), diff --git a/packages/core/interface/src/content/interfaces/content-picker.interface.ts b/packages/core/interface/src/content/interfaces/content-picker.interface.ts new file mode 100644 index 000000000..76fa38b7a --- /dev/null +++ b/packages/core/interface/src/content/interfaces/content-picker.interface.ts @@ -0,0 +1,3 @@ +import type { IContentSearchResult } from './content-search-result.interface'; + +export type ContentPickerHandler = (search: string) => Promise; diff --git a/packages/core/interface/src/content/interfaces/content-search-result.interface.ts b/packages/core/interface/src/content/interfaces/content-search-result.interface.ts new file mode 100644 index 000000000..774f3e4e7 --- /dev/null +++ b/packages/core/interface/src/content/interfaces/content-search-result.interface.ts @@ -0,0 +1,5 @@ +import type { IContent } from './content.interface'; + +export interface IContentSearchResult { + contents: IContent[]; +} diff --git a/packages/core/interface/src/content/interfaces/index.ts b/packages/core/interface/src/content/interfaces/index.ts index caebab9e1..9c7dbf8a7 100644 --- a/packages/core/interface/src/content/interfaces/index.ts +++ b/packages/core/interface/src/content/interfaces/index.ts @@ -1,2 +1,4 @@ export * from './content.interface'; export * from './renderable.interface'; +export * from './content-picker.interface'; +export * from './content-search-result.interface'; diff --git a/packages/core/interface/src/content/models/content-search-result.model.ts b/packages/core/interface/src/content/models/content-search-result.model.ts new file mode 100644 index 000000000..dc4885449 --- /dev/null +++ b/packages/core/interface/src/content/models/content-search-result.model.ts @@ -0,0 +1,13 @@ +import { Exclude, Expose } from 'class-transformer'; +import { ContentModel } from './content.model'; +import { BaseModel, type BaseModelData } from '@lyvely/common'; + +@Exclude() +export class ContentSearchResult { + @Expose() + result: ContentModel[]; + + constructor(data?: BaseModelData) { + BaseModel.init(this, data); + } +} diff --git a/packages/core/interface/src/content/models/content.filter.spec.ts b/packages/core/interface/src/content/models/content.filter.model.spec.ts similarity index 100% rename from packages/core/interface/src/content/models/content.filter.spec.ts rename to packages/core/interface/src/content/models/content.filter.model.spec.ts diff --git a/packages/core/interface/src/content/models/content.filter.ts b/packages/core/interface/src/content/models/content.filter.model.ts similarity index 100% rename from packages/core/interface/src/content/models/content.filter.ts rename to packages/core/interface/src/content/models/content.filter.model.ts diff --git a/packages/core/interface/src/content/models/index.ts b/packages/core/interface/src/content/models/index.ts index 8edb36356..3bff33119 100644 --- a/packages/core/interface/src/content/models/index.ts +++ b/packages/core/interface/src/content/models/index.ts @@ -1,5 +1,6 @@ export * from './content.model'; -export * from './content.filter'; +export * from './content.filter.model'; +export * from './content-search-result.model'; export * from './create-content.model'; export * from './content-update-response.model'; export * from './set-milestone.model'; diff --git a/packages/core/web/locales/en-us.json b/packages/core/web/locales/en-us.json index 0e408d9dd..6b87c347e 100644 --- a/packages/core/web/locales/en-us.json +++ b/packages/core/web/locales/en-us.json @@ -77,6 +77,7 @@ "empty_result": "No entries available.", "empty_result_filter": "No entries found for the given filter.", "close": "Close", + "content": "Content", "reload": "Reload", "cancel": "Cancel", "load_more": "Load more", diff --git a/packages/core/web/src/content/components/ContentDetails.vue b/packages/core/web/src/content/components/ContentDetails.vue index f28226a1a..509516fdb 100644 --- a/packages/core/web/src/content/components/ContentDetails.vue +++ b/packages/core/web/src/content/components/ContentDetails.vue @@ -7,6 +7,8 @@ import { useRouter } from 'vue-router'; import ContentMarkdown from './ContentMarkdown.vue'; import { LyIcon } from '@lyvely/ui'; import { useContentStreamEntryInfo } from './content-stream-entry-info.composable'; +import ContentPicker from './ContentPicker.vue'; +import type { IContent } from '@lyvely/interface/src'; export interface IProps { model: ContentModel; @@ -25,6 +27,10 @@ const router = useRouter(); function selectTag(tagId: string) { router.push({ name: 'stream', query: { tagIds: [tagId] } }); } + +const testHandler = (test: string) => ({ + contents: [{ id: 'asdf', content: { title: 'Test', text: 'TestText' } }] as IContent[], +});