diff --git a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html index d8c069ab..b719c2e3 100644 --- a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html +++ b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html @@ -5,12 +5,23 @@ asset-sg-menu-bar-item icon="assets" [link]="isActive ? null : [translateService.currentLang]" - [isActive]="isActive && ((isFiltersOpen$ | async) ?? false)" + [isActive]="isActive" (click)="isActive ? toggleAssetDrawer() : null" > menuBar.filters -
  • menuBar.favourites
  • + @if (userExists$ | async) { +
  • + menuBar.favourites +
  • + }
  • = this.router.events.pipe( filter((event) => event instanceof NavigationEnd), + startWith(() => undefined), map((): MenuItem | null => { - const segments = this.router.parseUrl(this.router.url).root.children['primary'].segments; - if (segments.length === 1) { + const segments = (this.router.getCurrentNavigation() ?? this.router.lastSuccessfulNavigation)?.finalUrl?.root + .children['primary'].segments; + if (segments == null || segments.length === 1) { return 'home'; } const path = segments.slice(1).join('/'); @@ -43,6 +45,9 @@ export class MenuBarComponent { if (isPath('asset-admin/new')) { return 'create-asset'; } + if (isPath('favorites')) { + return 'favorites'; + } if (path == 'asset-admin' || isPath('admin')) { return 'options'; } @@ -58,4 +63,4 @@ export class MenuBarComponent { protected readonly AssetEditPolicy = AssetEditPolicy; } -type MenuItem = 'home' | 'create-asset' | 'options'; +type MenuItem = 'home' | 'favorites' | 'create-asset' | 'options'; diff --git a/apps/client-asset-sg/src/app/i18n/de.ts b/apps/client-asset-sg/src/app/i18n/de.ts index 1c45b2ac..b18345c2 100644 --- a/apps/client-asset-sg/src/app/i18n/de.ts +++ b/apps/client-asset-sg/src/app/i18n/de.ts @@ -26,6 +26,9 @@ export const deAppTranslations = { nameTaken: "Der Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", }, }, + favorites: { + title: 'Favoriten', + }, menuBar: { filters: 'Filter', admin: 'Verwaltung', diff --git a/apps/client-asset-sg/src/app/i18n/en.ts b/apps/client-asset-sg/src/app/i18n/en.ts index 1b9194af..e3c9041c 100644 --- a/apps/client-asset-sg/src/app/i18n/en.ts +++ b/apps/client-asset-sg/src/app/i18n/en.ts @@ -28,6 +28,9 @@ export const enAppTranslations: AppTranslations = { nameTaken: "The name '{{name}}' is already taken by another workgroup.", }, }, + favorites: { + title: 'Favorites', + }, menuBar: { filters: 'Filters', admin: 'Administration', diff --git a/apps/client-asset-sg/src/app/i18n/fr.ts b/apps/client-asset-sg/src/app/i18n/fr.ts index 4bee145e..bcec595f 100644 --- a/apps/client-asset-sg/src/app/i18n/fr.ts +++ b/apps/client-asset-sg/src/app/i18n/fr.ts @@ -28,6 +28,9 @@ export const frAppTranslations: AppTranslations = { nameTaken: "Le nom '{{name}}' est déjà utilisé par un autre groupe de travail.", }, }, + favorites: { + title: 'Favorites', + }, menuBar: { filters: 'Filtres', admin: 'Administration', diff --git a/apps/client-asset-sg/src/app/i18n/it.ts b/apps/client-asset-sg/src/app/i18n/it.ts index 5520d37b..be72feb1 100644 --- a/apps/client-asset-sg/src/app/i18n/it.ts +++ b/apps/client-asset-sg/src/app/i18n/it.ts @@ -28,6 +28,9 @@ export const itAppTranslations: AppTranslations = { nameTaken: "IT Der Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", }, }, + favorites: { + title: 'IT Favoriten', + }, menuBar: { filters: 'IT Filter', admin: 'IT Verwaltung', diff --git a/apps/client-asset-sg/src/app/i18n/rm.ts b/apps/client-asset-sg/src/app/i18n/rm.ts index 7a678650..343c3e3e 100644 --- a/apps/client-asset-sg/src/app/i18n/rm.ts +++ b/apps/client-asset-sg/src/app/i18n/rm.ts @@ -28,6 +28,9 @@ export const rmAppTranslations: AppTranslations = { nameTaken: "RM Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", }, }, + favorites: { + title: 'RM Favoriten', + }, menuBar: { filters: 'RM Filter', admin: 'RM Verwaltung', diff --git a/apps/server-asset-sg/src/app.logger.ts b/apps/server-asset-sg/src/app.logger.ts index bd5b924b..840f596a 100644 --- a/apps/server-asset-sg/src/app.logger.ts +++ b/apps/server-asset-sg/src/app.logger.ts @@ -60,14 +60,30 @@ export class AppLogger implements LoggerService { if (!(message instanceof Error)) { output += level.color(`${message}`); } + const suffix = []; if (params.length !== 0 && !(params.length === 1 && params[0] === undefined)) { - output += ' ' + stringify(params, level); + let i = 0; + while (typeof params[i] === 'string') { + const line = params[i] as string; + suffix.push(line); + i += 1; + if (i >= params.length || line.endsWith('\n')) { + break; + } + } + params = params.slice(i); + if (params.length !== 0) { + output += ' ' + stringify(params, level); + } } + const args: unknown[] = [`${prefix} ${output}`]; if (message instanceof Error) { - console.log(`${prefix} ${output}`, message); - } else { - console.log(`${prefix} ${output}`); + args.push(message); } + if (suffix.length !== 0) { + args.push(level.color(`\n${suffix.join('\n')}`)); + } + console.log(...args); } } diff --git a/apps/server-asset-sg/src/app.module.ts b/apps/server-asset-sg/src/app.module.ts index 11aa94fb..af1491ef 100644 --- a/apps/server-asset-sg/src/app.module.ts +++ b/apps/server-asset-sg/src/app.module.ts @@ -36,12 +36,12 @@ import { WorkgroupsController } from '@/features/workgroups/workgroups.controlle @Module({ controllers: [ AppController, + FavoritesController, AssetEditController, AssetSearchController, AssetSyncController, AssetsController, ContactsController, - FavoritesController, FilesController, StudiesController, UsersController, diff --git a/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts index 76e25576..d7fa57c9 100644 --- a/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts @@ -8,7 +8,7 @@ import { fakeAssetFormatItemCode } from '../../../../../test/data/asset-format-i // eslint-disable-next-line @nx/enforce-module-boundaries import { fakeAssetKindItemCode } from '../../../../../test/data/asset-kind-item'; // eslint-disable-next-line @nx/enforce-module-boundaries -import { fakeContactKindItem } from '../../../../../test/data/contact-kind-item'; +import { fakeContactKindItemCode } from '../../../../../test/data/contact-kind-item'; import { define } from '@/utils/define'; @@ -35,7 +35,7 @@ export const fakeUser = () => { export const fakeContact = () => define>({ - contactKindItemCode: fakeContactKindItem(), + contactKindItemCode: fakeContactKindItemCode(), name: faker.company.name(), street: faker.location.street(), houseNumber: faker.location.buildingNumber(), diff --git a/apps/server-asset-sg/src/features/assets/asset-info.repo.ts b/apps/server-asset-sg/src/features/assets/asset-info.repo.ts index f174d8bb..c75f6853 100644 --- a/apps/server-asset-sg/src/features/assets/asset-info.repo.ts +++ b/apps/server-asset-sg/src/features/assets/asset-info.repo.ts @@ -1,8 +1,10 @@ -import { AssetId, AssetInfo } from '@asset-sg/shared/v2'; +import { AssetId, AssetInfo, UserId } from '@asset-sg/shared/v2'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from '@/core/prisma.service'; import { ReadRepo, RepoListOptions } from '@/core/repo'; import { assetInfoSelection, parseAssetInfoFromPrisma } from '@/features/assets/prisma-asset'; +@Injectable() export class AssetInfoRepo implements ReadRepo { constructor(private readonly prisma: PrismaService) {} @@ -28,4 +30,18 @@ export class AssetInfoRepo implements ReadRepo { }); return entries.map(parseAssetInfoFromPrisma); } + + async listFavorites(userId: UserId): Promise { + const entries = await this.prisma.asset.findMany({ + where: { + favorites: { + some: { + userId: userId, + }, + }, + }, + select: assetInfoSelection, + }); + return entries.map(parseAssetInfoFromPrisma); + } } diff --git a/apps/server-asset-sg/src/features/assets/asset.repo.ts b/apps/server-asset-sg/src/features/assets/asset.repo.ts index 4ad86e6e..f3a196e8 100644 --- a/apps/server-asset-sg/src/features/assets/asset.repo.ts +++ b/apps/server-asset-sg/src/features/assets/asset.repo.ts @@ -14,8 +14,8 @@ import { handlePrismaMutationError } from '@/utils/prisma'; export class AssetRepo implements FindRepo, MutateRepo { constructor(private readonly prisma: PrismaService) {} - async count() { - return await this.prisma.asset.count(); + async count(): Promise { + return this.prisma.asset.count(); } async find(id: AssetId): Promise { diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts index f3505308..a1262e2f 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts @@ -35,7 +35,7 @@ export class AssetSearchController { limit = limit == null ? limit : Number(limit); offset = offset == null ? offset : Number(offset); restrictQueryForUser(query, user); - const result = await this.assetSearchService.search(query, { limit, offset, decode: false }); + const result = await this.assetSearchService.search(query, user, { limit, offset, decode: false }); return plainToInstance(AssetSearchResultDTO, result); } @@ -48,7 +48,7 @@ export class AssetSearchController { @CurrentUser() user: User ): Promise { restrictQueryForUser(query, user); - const stats = await this.assetSearchService.aggregate(query); + const stats = await this.assetSearchService.aggregate(query, user); return plainToInstance(AssetSearchStatsDTO, stats); } } diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts index 3a9aa4c3..a6fccd39 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts @@ -145,7 +145,7 @@ describe(AssetSearchService, () => { await create({ patch: fakeAssetPatch(), user }); // When - const result = await service.search({ text: `${text}` }); + const result = await service.search({ text: `${text}` }, user); // Then assertSingleResult(result, asset); @@ -194,11 +194,14 @@ describe(AssetSearchService, () => { }); // When - const result = await service.search({ - createDate: { - min: new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay), + const result = await service.search( + { + createDate: { + min: new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay), + }, }, - }); + user + ); // Then assertSingleResult(result, asset); @@ -217,11 +220,14 @@ describe(AssetSearchService, () => { }); // When - const result = await service.search({ - createDate: { - max: new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay), + const result = await service.search( + { + createDate: { + max: new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay), + }, }, - }); + user + ); // Then assertSingleResult(result, asset); @@ -244,14 +250,18 @@ describe(AssetSearchService, () => { }, user: fakeUser(), }); + const user = fakeUser(); // When - const result = await service.search({ - createDate: { - min: new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay), - max: new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay), + const result = await service.search( + { + createDate: { + min: new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay), + max: new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay), + }, }, - }); + user + ); // Then assertSingleResult(result, asset); @@ -274,9 +284,10 @@ describe(AssetSearchService, () => { patch: { ...fakeAssetPatch(), assetLanguages: [{ languageItemCode: code3 }] }, user: fakeUser(), }); + const user = fakeUser(); // When - const result = await service.search({ languageItemCodes: [code1] }); + const result = await service.search({ languageItemCodes: [code1] }, user); // Then assertSingleResult(result, asset); @@ -293,7 +304,7 @@ describe(AssetSearchService, () => { await create({ patch: { ...fakeAssetPatch(), assetKindItemCode: code3 }, user }); // When - const result = await service.search({ assetKindItemCodes: [code1] }); + const result = await service.search({ assetKindItemCodes: [code1] }, user); // Then assertSingleResult(result, asset); @@ -310,7 +321,7 @@ describe(AssetSearchService, () => { await create({ patch: { ...fakeAssetPatch(), manCatLabelRefs: [code3] }, user }); // When - const result = await service.search({ manCatLabelItemCodes: [code1] }); + const result = await service.search({ manCatLabelItemCodes: [code1] }, user); // Then assertSingleResult(result, asset); @@ -346,7 +357,7 @@ describe(AssetSearchService, () => { }); // When - const result = await service.search({ usageCodes: [usageCode] }); + const result = await service.search({ usageCodes: [usageCode] }, user); // Then assertSingleResult(result, asset); @@ -371,7 +382,7 @@ describe(AssetSearchService, () => { }); // When - const result = await service.search({ authorId: contact1.contactId }); + const result = await service.search({ authorId: contact1.contactId }, user); // Then assertSingleResult(result, asset); @@ -417,8 +428,11 @@ describe(AssetSearchService, () => { }; it('returns empty stats when no assets are present', async () => { + // Given + const user = fakeUser(); + // When - const result = await service.aggregate({}); + const result = await service.aggregate({}, user); // Then expect(result.total).toEqual(0); @@ -436,7 +450,7 @@ describe(AssetSearchService, () => { const asset = await create({ patch: fakeAssetPatch(), user }); // When - const result = await service.aggregate({}); + const result = await service.aggregate({}, user); // Then assertSingleStats(result, asset); diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts index 40d4f4a5..8a75f910 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts @@ -19,9 +19,10 @@ import { UsageCode, ValueCount, } from '@asset-sg/shared'; -import { AssetId, StudyId } from '@asset-sg/shared/v2'; +import { AssetId, StudyId, User } from '@asset-sg/shared/v2'; import { Client as ElasticsearchClient } from '@elastic/elasticsearch'; import { + AggregationsAggregationContainer, BulkOperationContainer, QueryDslNumberRangeQuery, QueryDslQueryContainer, @@ -175,6 +176,7 @@ export class AssetSearchService { * Searches for assets using a {@link AssetSearchQuery}. * * @param query The query to match with. + * @param user The user that is executing the query. * @param limit The maximum amount of assets to load. Defaults to `100`. * @param offset The amount of assets being skipped before loading the assets. * @param decode Whether to decode the assets. If this is set to `false`, the assets should be decoded via `AssetEditDetail` before accessing them. @@ -182,10 +184,11 @@ export class AssetSearchService { */ async search( query: AssetSearchQuery, + user: User, { limit = 100, offset = 0, decode = true }: PageOptions & { decode?: boolean } = {} ): Promise { // Apply the query to find all matching ids. - const [serializedAssets, total] = await this.searchAssetsByQuery(query, { limit, offset }); + const [serializedAssets, total] = await this.searchAssetsByQuery(query, user, { limit, offset }); // Load the matched assets from the database. const data: AssetEditDetail[] = []; @@ -209,8 +212,15 @@ export class AssetSearchService { * Aggregates the stats over all assets matching a specific {@link AssetSearchQuery}. * * @param query The query to match with. + * @param user The user that is executing the query. */ - async aggregate(query: AssetSearchQuery): Promise { + async aggregate(query: AssetSearchQuery, user: User): Promise { + type NestedAggResult = { + [K in keyof T]: { + a: T[K]; + }; + }; + interface Result { minCreateDate: { value: DateId }; maxCreateDate: { value: DateId }; @@ -242,36 +252,37 @@ export class AssetSearchService { doc_count: number; } - const aggregateGroup = async ( - query: AssetSearchQuery, + const makeAggregation = ( operator: 'terms' | 'min' | 'max', groupName: string, fieldName?: string - ) => { + ): AggregationsAggregationContainer => { const NUMBER_OF_BUCKETS = 10_000; - const elasticDslQuery = mapQueryToElasticDsl({ ...query, [groupName]: undefined }); + const { filter } = mapQueryToElasticDslParts({ ...query, [groupName]: undefined }, user); const field: { field: string; size?: number } = { field: fieldName ?? groupName, size: NUMBER_OF_BUCKETS }; if (operator !== 'terms') { delete field.size; } - return ( - await this.elastic.search({ - index: INDEX, - size: 0, - query: elasticDslQuery, - track_total_hits: true, - aggregations: { - agg: { [operator]: field }, - }, - }) - ).aggregations?.agg; + return { aggs: { a: { [operator]: field } }, filter: { bool: { filter } } }; + }; + + const { must, filter } = mapQueryToElasticDslParts(query, user); + + const aggregateByQuery = async (aggs: Record) => { + return await this.elastic.search({ + index: INDEX, + size: 0, + query: { bool: { must } }, + track_total_hits: true, + aggregations: aggs, + filter_path: ['aggregations.*.a.buckets.*', 'aggregations.*.a.value'], + }); }; - const elasticQuery = mapQueryToElasticDsl(query); const response = await this.elastic.search({ index: INDEX, size: 0, - query: elasticQuery, + query: { bool: { must, filter } }, track_total_hits: true, }); const total = (response.hits.total as SearchTotalHits).value; @@ -289,38 +300,19 @@ export class AssetSearchService { }; } - const [ - assetKindItemCodes, - authorIds, - languageItemCodes, - geometryCodes, - manCatLabelItemCodes, - usageCodes, - workgroupIds, - minCreateDate, - maxCreateDate, - ] = await Promise.all([ - aggregateGroup(query, 'terms', 'assetKindItemCodes', 'assetKindItemCode'), - aggregateGroup(query, 'terms', 'authorIds'), - aggregateGroup(query, 'terms', 'languageItemCodes'), - aggregateGroup(query, 'terms', 'geometryCodes'), - aggregateGroup(query, 'terms', 'manCatLabelItemCodes'), - aggregateGroup(query, 'terms', 'usageCodes', 'usageCode'), - aggregateGroup(query, 'terms', 'workgroupIds', 'workgroupId'), - aggregateGroup(query, 'min', 'minCreateDate', 'createDate'), - aggregateGroup(query, 'max', 'maxCreateDate', 'createDate'), - ]); - const aggs = { - assetKindItemCodes, - authorIds, - languageItemCodes, - geometryCodes, - manCatLabelItemCodes, - usageCodes, - workgroupIds, - minCreateDate, - maxCreateDate, - } as unknown as Result; + const result = await aggregateByQuery({ + assetKindItemCodes: makeAggregation('terms', 'assetKindItemCodes', 'assetKindItemCode'), + authorIds: makeAggregation('terms', 'authorIds'), + languageItemCodes: makeAggregation('terms', 'languageItemCodes'), + geometryCodes: makeAggregation('terms', 'geometryCodes'), + manCatLabelItemCodes: makeAggregation('terms', 'manCatLabelItemCodes'), + usageCodes: makeAggregation('terms', 'usageCodes', 'usageCode'), + workgroupIds: makeAggregation('terms', 'workgroupIds', 'workgroupId'), + minCreateDate: makeAggregation('min', 'minCreateDate', 'createDate'), + maxCreateDate: makeAggregation('max', 'maxCreateDate', 'createDate'), + }); + + const aggs = result.aggregations as unknown as NestedAggResult; const mapBucket = (bucket: AggregationBucket): ValueCount => ({ value: bucket.key, @@ -328,16 +320,16 @@ export class AssetSearchService { }); return { total, - assetKindItemCodes: aggs.assetKindItemCodes.buckets.map(mapBucket), - authorIds: aggs.authorIds.buckets.map(mapBucket), - languageItemCodes: aggs.languageItemCodes.buckets.map(mapBucket), - geometryCodes: aggs.geometryCodes.buckets.map(mapBucket), - manCatLabelItemCodes: aggs.manCatLabelItemCodes.buckets.map(mapBucket), - usageCodes: aggs.usageCodes.buckets.map(mapBucket), - workgroupIds: aggs.workgroupIds.buckets.map(mapBucket), + assetKindItemCodes: aggs.assetKindItemCodes?.a?.buckets?.map(mapBucket) ?? [], + authorIds: aggs.authorIds?.a?.buckets?.map(mapBucket) ?? [], + languageItemCodes: aggs.languageItemCodes?.a?.buckets?.map(mapBucket) ?? [], + geometryCodes: aggs.geometryCodes?.a?.buckets?.map(mapBucket) ?? [], + manCatLabelItemCodes: aggs.manCatLabelItemCodes?.a?.buckets?.map(mapBucket) ?? [], + usageCodes: aggs.usageCodes?.a?.buckets?.map(mapBucket) ?? [], + workgroupIds: aggs.workgroupIds?.a?.buckets?.map(mapBucket) ?? [], createDate: { - min: dateFromDateId(aggs.minCreateDate.value), - max: dateFromDateId(aggs.maxCreateDate.value), + min: dateFromDateId(aggs.minCreateDate.a.value), + max: dateFromDateId(aggs.maxCreateDate.a.value), }, }; } @@ -379,11 +371,12 @@ export class AssetSearchService { private async searchAssetsByQuery( query: AssetSearchQuery, + user: User, page: PageOptions = {} ): Promise<[Map, number]> { const BATCH_SIZE = 10_000; - const elasticQuery = mapQueryToElasticDsl(query); + const elasticQuery = mapQueryToElasticDsl(query, user); const matchedAssets = new Map(); let lastAssetId: number | null = null; let totalCount: number | null = null; @@ -677,6 +670,20 @@ export class AssetSearchService { const languageItemCodes = asset.assetLanguages.length === 0 ? ['None'] : asset.assetLanguages.map((it) => it.languageItemCode); + + const favoredByUsers = await this.prisma.assetUser.findMany({ + select: { + id: true, + }, + where: { + favorites: { + some: { + assetId: asset.assetId, + }, + }, + }, + }); + return { assetId: asset.assetId, titlePublic: asset.titlePublic, @@ -692,6 +699,7 @@ export class AssetSearchService { geometryCodes: geometryCodes.length > 0 ? [...new Set(geometryCodes)] : ['None'], studyLocations, workgroupId: asset.workgroupId, + favoredByUserIds: favoredByUsers.map(({ id }) => id), data: JSON.stringify(AssetEditDetail.encode(asset)), }; } @@ -706,7 +714,20 @@ const mapLv95ToElastic = (lv95: LV95): ElasticPoint => { return { lat: wgs[1], lon: wgs[0] }; }; -const mapQueryToElasticDsl = (query: AssetSearchQuery): QueryDslQueryContainer => { +const mapQueryToElasticDsl = (query: AssetSearchQuery, user: User): QueryDslQueryContainer => { + const { must, filter } = mapQueryToElasticDslParts(query, user); + return { + bool: { + must, + filter, + }, + }; +}; + +const mapQueryToElasticDslParts = ( + query: AssetSearchQuery, + user: User +): { must: QueryDslQueryContainer[]; filter: QueryDslQueryContainer[] } => { const scope = ['titlePublic', 'titleOriginal', 'contactNames', 'sgsId']; const queries: QueryDslQueryContainer[] = []; const filters: QueryDslQueryContainer[] = []; @@ -767,6 +788,14 @@ const mapQueryToElasticDsl = (query: AssetSearchQuery): QueryDslQueryContainer = }, }); } + if (query.favoritesOnly) { + filters.push({ + terms: { + favoredByUserIds: [user.id], + }, + }); + } + if (query.polygon != null) { queries.push({ geo_polygon: { @@ -776,12 +805,9 @@ const mapQueryToElasticDsl = (query: AssetSearchQuery): QueryDslQueryContainer = }, }); } - return { - bool: { - must: queries, - filter: filters, - }, + must: queries, + filter: filters, }; }; diff --git a/apps/server-asset-sg/src/features/favorites/favorite.repo.ts b/apps/server-asset-sg/src/features/favorites/favorite.repo.ts index 6b3220ba..6d2d78f7 100644 --- a/apps/server-asset-sg/src/features/favorites/favorite.repo.ts +++ b/apps/server-asset-sg/src/features/favorites/favorite.repo.ts @@ -13,9 +13,9 @@ export class FavoriteRepo implements ReadRepo, CreateRepo { - const entry = await this.prisma.assetUserFavourite.findUnique({ + const entry = await this.prisma.favorite.findUnique({ where: { - assetUserId_assetId: mapFavoriteToId(favorite), + userId_assetId: mapFavoriteToId(favorite), }, select: favoriteSelection, }); @@ -23,7 +23,7 @@ export class FavoriteRepo implements ReadRepo, CreateRepo = {}): Promise { - const entries = await this.prisma.assetUserFavourite.findMany({ + const entries = await this.prisma.favorite.findMany({ where: ids == null ? undefined @@ -38,9 +38,9 @@ export class FavoriteRepo implements ReadRepo, CreateRepo { - const entries = await this.prisma.assetUserFavourite.findMany({ + const entries = await this.prisma.favorite.findMany({ where: { - assetUserId: userId, + userId: userId, }, select: favoriteSelection, }); @@ -49,10 +49,9 @@ export class FavoriteRepo implements ReadRepo, CreateRepo { try { - const entry = await this.prisma.assetUserFavourite.create({ + const entry = await this.prisma.favorite.create({ data: { ...mapFavoriteToId(favorite), - created_at: new Date(), }, select: favoriteSelection, }); @@ -69,9 +68,9 @@ export class FavoriteRepo implements ReadRepo, CreateRepo { try { - await this.prisma.assetUserFavourite.delete({ + await this.prisma.favorite.delete({ where: { - assetUserId_assetId: mapFavoriteToId(favorite), + userId_assetId: mapFavoriteToId(favorite), }, }); return true; @@ -81,19 +80,19 @@ export class FavoriteRepo implements ReadRepo, CreateRepo()({ - assetUserId: true, +const favoriteSelection = satisfy()({ + userId: true, assetId: true, }); -type SelectedFavorite = Prisma.AssetUserFavouriteGetPayload<{ select: typeof favoriteSelection }>; +type SelectedFavorite = Prisma.FavoriteGetPayload<{ select: typeof favoriteSelection }>; const parse = (data: SelectedFavorite): Favorite => ({ assetId: data.assetId, - userId: data.assetUserId, + userId: data.userId, }); -const mapFavoriteToId = (favorite: Favorite): Prisma.AssetUserFavouriteAssetUserIdAssetIdCompoundUniqueInput => ({ - assetUserId: favorite.userId, +const mapFavoriteToId = (favorite: Favorite): Prisma.FavoriteUserIdAssetIdCompoundUniqueInput => ({ + userId: favorite.userId, assetId: favorite.assetId, }); diff --git a/apps/server-asset-sg/src/features/favorites/favorites.controller.ts b/apps/server-asset-sg/src/features/favorites/favorites.controller.ts index f1356277..42594ee9 100644 --- a/apps/server-asset-sg/src/features/favorites/favorites.controller.ts +++ b/apps/server-asset-sg/src/features/favorites/favorites.controller.ts @@ -1,40 +1,70 @@ -import { SearchAssetResult, SearchAssetResultEmpty } from '@asset-sg/shared'; -import { User } from '@asset-sg/shared/v2'; -import { Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Put } from '@nestjs/common'; +import { AssetId, AssetInfo, FavoritePolicy, User } from '@asset-sg/shared/v2'; +import { + Controller, + Delete, + Get, + HttpCode, + HttpException, + HttpStatus, + Param, + ParseIntPipe, + Post, +} from '@nestjs/common'; +import { authorize } from '@/core/authorize'; import { Authorize } from '@/core/decorators/authorize.decorator'; import { CurrentUser } from '@/core/decorators/current-user.decorator'; +import { AssetEditRepo } from '@/features/asset-edit/asset-edit.repo'; +import { AssetInfoRepo } from '@/features/assets/asset-info.repo'; import { AssetSearchService } from '@/features/assets/search/asset-search.service'; import { FavoriteRepo } from '@/features/favorites/favorite.repo'; -import { define } from '@/utils/define'; -@Controller('/users/current/favorites') +@Controller('/assets/favorites') export class FavoritesController { - constructor(private readonly favoriteRepo: FavoriteRepo, private readonly assetSearchService: AssetSearchService) {} + constructor( + private readonly favoriteRepo: FavoriteRepo, + private readonly assetInfoRepo: AssetInfoRepo, + private readonly assetEditRepo: AssetEditRepo, + private readonly assetSearchService: AssetSearchService + ) {} - // TODO make an alternative, new endpoint for this that does not use fp-ts. @Get('/') @Authorize.User() - async list(@CurrentUser() user: User): Promise { - const favorites = await this.favoriteRepo.listByUserId(user.id); - if (favorites.length === 0) { - return define({ _tag: 'SearchAssetResultEmpty' }); - } - const assetIds = favorites.map((it) => it.assetId); - return await this.assetSearchService.searchOld('', { scope: ['titlePublic'], assetIds }); + async list(@CurrentUser() user: User): Promise { + return await this.assetInfoRepo.listFavorites(user.id); + } + + @Get('/ids') + @Authorize.User() + async listIds(@CurrentUser() user: User): Promise { + const assets = await this.assetInfoRepo.listFavorites(user.id); + return assets.map(({ id }) => id); } - @Put('/:assetId') + @Post('/:assetId') @Authorize.User() @HttpCode(HttpStatus.NO_CONTENT) - async add(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { + async create(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { + authorize(FavoritePolicy, user).canCreate(); + const asset = await this.assetEditRepo.find(assetId); + if (asset == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } await this.favoriteRepo.create({ userId: user.id, assetId }); + await this.assetSearchService.register(asset); } @Delete('/:assetId') @Authorize.User() @HttpCode(HttpStatus.NO_CONTENT) - async remove(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { - await this.favoriteRepo.delete({ userId: user.id, assetId }); + async delete(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { + const asset = await this.assetEditRepo.find(assetId); + if (asset == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + const favorite = { userId: user.id, assetId }; + authorize(FavoritePolicy, user).canDelete(favorite); + await this.favoriteRepo.delete(favorite); + await this.assetSearchService.register(asset); } } diff --git a/apps/server-asset-sg/src/features/favorites/favorites.http b/apps/server-asset-sg/src/features/favorites/favorites.http index f7246395..e9dd6243 100644 --- a/apps/server-asset-sg/src/features/favorites/favorites.http +++ b/apps/server-asset-sg/src/features/favorites/favorites.http @@ -1,11 +1,13 @@ -### Show favourites -GET {{host}}/api/users/current/favorites +@assetId = 12 + +### List favourites +GET {{host}}/api/assets/favorites Authorization: Impersonate {{user}} ### Add favourite -PUT {{host}}/api/users/current/favorites/5 +POST {{host}}/api/assets/favorites/{{assetId}} Authorization: Impersonate {{user}} ### Remove favourite -DELETE {{host}}/api/users/current/favorites/5 +DELETE {{host}}/api/assets/favorites/{{assetId}} Authorization: Impersonate {{user}} diff --git a/apps/server-asset-sg/src/features/users/user.repo.ts b/apps/server-asset-sg/src/features/users/user.repo.ts index e7bbf11f..3e1fda49 100644 --- a/apps/server-asset-sg/src/features/users/user.repo.ts +++ b/apps/server-asset-sg/src/features/users/user.repo.ts @@ -92,7 +92,7 @@ export class UserRepo implements Repo { // The schema does not define how to handle deletes on users with existing favourites, // so we need to delete them manually. - await this.prisma.assetUserFavourite.deleteMany({ where: { assetUserId: id } }); + await this.prisma.favorite.deleteMany({ where: { userId: id } }); await this.prisma.assetUser.delete({ where: { id } }); }); return true; diff --git a/apps/server-asset-sg/src/main.ts b/apps/server-asset-sg/src/main.ts index b79fe2d7..128e38fe 100644 --- a/apps/server-asset-sg/src/main.ts +++ b/apps/server-asset-sg/src/main.ts @@ -11,6 +11,8 @@ export * from '@prisma/client'; const API_PREFIX = 'api'; const API_PORT = process.env.PORT || 3333; +process.on('warning', (e) => console.warn(e.stack)); + async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { logger: new AppLogger(), diff --git a/development/init/elasticsearch/mappings/swissgeol_asset_asset.json b/development/init/elasticsearch/mappings/swissgeol_asset_asset.json index b4810b82..e0f5f259 100644 --- a/development/init/elasticsearch/mappings/swissgeol_asset_asset.json +++ b/development/init/elasticsearch/mappings/swissgeol_asset_asset.json @@ -59,6 +59,9 @@ }, "studyLocations": { "type": "geo_point" + }, + "favoredByUserIds": { + "type": "keyword" } } } diff --git a/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts b/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts index 2832aa27..d343019f 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts @@ -4,6 +4,7 @@ import { ChangeDetectorRef, Component, inject, + OnDestroy, TemplateRef, ViewChild, ViewContainerRef, @@ -26,7 +27,7 @@ import { asyncScheduler, observeOn } from 'rxjs'; hostDirectives: [LifecycleHooksDirective], styleUrls: ['./asset-editor-launch.component.scss'], }) -export class AssetEditorLaunchComponent { +export class AssetEditorLaunchComponent implements OnDestroy { @ViewChild('templateDrawerPortalContent') _templateDrawerPortalContent!: TemplateRef; private _lc = inject(LifecycleHooks); @@ -46,4 +47,9 @@ export class AssetEditorLaunchComponent { this._store.dispatch(appSharedStateActions.openPanel()); }); } + + ngOnDestroy(): void { + this._appPortalService.setAppBarPortalContent(null); + this._appPortalService.setDrawerPortalContent(null); + } } diff --git a/libs/asset-viewer/src/lib/asset-viewer.module.ts b/libs/asset-viewer/src/lib/asset-viewer.module.ts index a215d0cf..a5b31206 100644 --- a/libs/asset-viewer/src/lib/asset-viewer.module.ts +++ b/libs/asset-viewer/src/lib/asset-viewer.module.ts @@ -40,6 +40,7 @@ import { ValueItemNamePipe, ZoomControlsComponent, } from '@asset-sg/client-shared'; +import { FavoritesModule } from '@asset-sg/favorites'; import { SvgIconComponent } from '@ngneat/svg-icon'; import { EffectsModule } from '@ngrx/effects'; import { StoreModule } from '@ngrx/store'; @@ -51,7 +52,7 @@ import { de } from 'date-fns/locale/de'; import { AssetPickerComponent } from './components/asset-picker'; import { AssetSearchDetailComponent } from './components/asset-search-detail'; -import { AssetSearchFilterListComponent } from './components/asset-search-filter-list/asset-search-filter-list.component'; +import { AssetSearchFilterComponent } from './components/asset-search-filter/asset-search-filter.component'; import { AssetSearchRefineComponent } from './components/asset-search-refine'; import { AssetSearchResultsComponent } from './components/asset-search-results'; import { AssetViewerFilesComponent } from './components/asset-viewer-files/asset-viewer-files.component'; @@ -69,13 +70,14 @@ import { mapControlReducer } from './state/map-control/map-control.reducer'; MapControlsComponent, AssetSearchDetailComponent, AssetSearchRefineComponent, - AssetSearchFilterListComponent, + AssetSearchFilterComponent, AssetSearchResultsComponent, AssetViewerFilesComponent, AssetPickerComponent, ], imports: [ CommonModule, + FavoritesModule, RouterModule.forChild([ { path: '', diff --git a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.html b/libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.html similarity index 100% rename from libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.html rename to libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.html diff --git a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.scss b/libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.scss similarity index 100% rename from libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.scss rename to libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.scss diff --git a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts b/libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.ts similarity index 80% rename from libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts rename to libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.ts index bc03ba71..897b2564 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.ts @@ -5,11 +5,11 @@ import { AppStateWithAssetSearch } from '../../state/asset-search/asset-search.r import { Filter } from '../../state/asset-search/asset-search.selector'; @Component({ - selector: 'asset-sg-asset-search-filter-list', - templateUrl: './asset-search-filter-list.component.html', - styleUrl: './asset-search-filter-list.component.scss', + selector: 'asset-sg-asset-search-filter', + templateUrl: './asset-search-filter.component.html', + styleUrl: './asset-search-filter.component.scss', }) -export class AssetSearchFilterListComponent { +export class AssetSearchFilterComponent { @Input({ required: true }) filters!: Array>; diff --git a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html index 341041c6..ed724854 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html +++ b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html @@ -20,27 +20,27 @@

    search.searchControl

    search.refineSearch

    search.workgroup

    - +

    search.usage

    - +

    search.geometry

    - +

    search.language

    - +

    search.kind

    - +

    search.topic

    - +

    search.author

    diff --git a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts index 5d0e25db..2458901e 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts @@ -55,6 +55,7 @@ export class AssetSearchRefineComponent implements OnInit, OnDestroy, AfterViewI readonly languageFilters$ = this.store.select(selectLanguageFilters); readonly assetKindFilters$ = this.store.select(selectAssetKindFilters); readonly workgroupFilters$ = this.store.select(selectWorkgroupFilters); + readonly isDrawActive$ = this.store.select(selectMapControlIsDrawing); private readonly subscriptions: Subscription = new Subscription(); diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html index 48a41684..7e007814 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html @@ -4,7 +4,7 @@ {{ "search.searchResults" | translate }}: - @@ -16,9 +16,7 @@ - + diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.scss b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.scss index 6808d8e9..d820c8d8 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.scss +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.scss @@ -25,12 +25,6 @@ height: 400px; } -.asset-favourite { - height: 2.5rem; - width: 2.5rem; - color: variables.$cyan-09; -} - .mat-mdc-header-row .mat-mdc-cell { background-color: variables.$grey-01; } diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts index 3f19ad40..8428892b 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts @@ -68,7 +68,7 @@ export class AssetSearchResultsComponent implements OnInit, OnDestroy { this._store.dispatch(actions.assetClicked({ assetId })); } - public toggleResultsOpen(isCurrentlyOpen: boolean) { + public toggleResultsOpen() { this._store.dispatch(actions.manualToggleResult()); } diff --git a/libs/asset-viewer/src/lib/components/map/map-controller.ts b/libs/asset-viewer/src/lib/components/map/map-controller.ts index 14964b9b..12337c45 100644 --- a/libs/asset-viewer/src/lib/components/map/map-controller.ts +++ b/libs/asset-viewer/src/lib/components/map/map-controller.ts @@ -56,6 +56,8 @@ export class MapController { private showHeatmap = true; + private isInitialized = false; + constructor(element: HTMLElement) { const view = new View({ projection: 'EPSG:3857', @@ -97,6 +99,7 @@ export class MapController { this.assetsHover$ = this.makeAssetsHover$(); this.map.once('loadend', () => { + this.isInitialized = true; if (this.activeAsset === null) { const zoom = view.getZoom(); if (zoom != null) { @@ -184,7 +187,9 @@ export class MapController { this.sources.assets.clear(); this.sources.assets.addFeatures(features); this.sources.picker.clear(); - zoomToStudies(this.map, studies); + if (this.isInitialized) { + zoomToStudies(this.map, studies); + } }); } diff --git a/libs/asset-viewer/src/lib/components/map/map.component.ts b/libs/asset-viewer/src/lib/components/map/map.component.ts index 33a7051f..6691c319 100644 --- a/libs/asset-viewer/src/lib/components/map/map.component.ts +++ b/libs/asset-viewer/src/lib/components/map/map.component.ts @@ -21,7 +21,7 @@ import { selectAssetSearchPolygon, selectAssetSearchResultData, selectCurrentAssetDetail, - selectAssetSearchNoActiveFilters, + selectHasDefaultFilters, selectStudies, } from '../../state/asset-search/asset-search.selector'; import { AppStateWithMapControl } from '../../state/map-control/map-control.reducer'; @@ -128,6 +128,14 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { this.controller.addControl(this.controls.zoom); this.controller.addControl(this.controls.draw); + this.subscription.add( + this.store.select(selectHasDefaultFilters).subscribe((hasDefaultFilters) => { + if (hasDefaultFilters) { + this.controls.zoom.resetZoom(); + } + }) + ); + this.controls.draw.isDrawing$.subscribe((isDrawing) => { this.controller.setClickEnabled(!isDrawing); }); @@ -142,13 +150,12 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { this.initializeStoreBindings(); this.handleHighlightedAssetIdChange(); }); - this.initializeEnd.emit(); } private initializeStoreBindings() { this.subscription.add( - this.store.select(selectAssetSearchNoActiveFilters).subscribe((showStudies) => { + this.store.select(selectHasDefaultFilters).subscribe((showStudies) => { this.controller.setShowHeatmap(showStudies); }) ); diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.effects.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.effects.ts index 2d886f9a..2683c717 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.effects.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.effects.ts @@ -6,20 +6,20 @@ import { AssetSearchQuery, AssetSearchResult, LV95, Polygon } from '@asset-sg/sh import * as RD from '@devexperts/remote-data-ts'; import { UntilDestroy } from '@ngneat/until-destroy'; import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; -import { ROUTER_NAVIGATED } from '@ngrx/router-store'; +import { ROUTER_NAVIGATED, RouterNavigatedPayload } from '@ngrx/router-store'; import { Store } from '@ngrx/store'; import * as D from 'io-ts/Decoder'; -import { EMPTY, filter, map, merge, of, shareReplay, switchMap, takeUntil, withLatestFrom } from 'rxjs'; +import { EMPTY, filter, identity, map, merge, of, shareReplay, switchMap, withLatestFrom } from 'rxjs'; import { AllStudyService } from '../../services/all-study.service'; import { AssetSearchService } from '../../services/asset-search.service'; import * as actions from './asset-search.actions'; import { - selectAssetSearchNoActiveFilters, selectAssetSearchQuery, selectAssetSearchResultData, selectCurrentAssetDetail, + selectHasDefaultFilters, selectStudies, } from './asset-search.selector'; @@ -117,17 +117,16 @@ export class AssetSearchEffects { public openSearchResults$ = createEffect(() => { return this.actions$.pipe( ofType(actions.updateSearchResults), - takeUntil(this.actions$.pipe(ofType(actions.manualToggleResult))), - withLatestFrom(this.store.select(selectAssetSearchNoActiveFilters)), - map(([_, showStudies]) => (showStudies ? actions.closeResults() : actions.openResults())) + switchMap(() => this.store.select(selectHasDefaultFilters)), + map((showStudies) => (showStudies ? actions.closeResults() : actions.openResults())) ); }); public closeSearchResults$ = createEffect(() => { return this.actions$.pipe( ofType(actions.updateSearchResults), - withLatestFrom(this.store.select(selectAssetSearchNoActiveFilters)), - filter(([_, showStudies]) => showStudies), + switchMap(() => this.store.select(selectHasDefaultFilters)), + filter(identity), map(() => actions.closeResults()) ); }); @@ -147,8 +146,13 @@ export class AssetSearchEffects { */ public queryParams$ = this.actions$.pipe( ofType(ROUTER_NAVIGATED), - filter((x) => assetsPageMatcher(x.payload.routerState.root.firstChild.url) !== null), + filter( + ({ payload }: { payload: RouterNavigatedPayload }) => + assetsPageMatcher(payload.routerState.root.firstChild?.url ?? []) !== null + ), map(({ payload }) => { + const routerState = payload.routerState; + const url = routerState.root.firstChild?.url ?? []; const params = payload.routerState.root.queryParams; const query: AssetSearchQuery = {}; const assetId: number | undefined = readNumberParam(params, QUERY_PARAM_MAPPING.assetId); @@ -164,6 +168,7 @@ export class AssetSearchEffects { query.geometryCodes = readArrayParam(params, QUERY_PARAM_MAPPING.geometryCodes); query.languageItemCodes = readArrayParam(params, QUERY_PARAM_MAPPING.languageItemCodes); query.workgroupIds = readArrayParam(params, QUERY_PARAM_MAPPING.workgroupIds); + query.favoritesOnly = url.length === 2 && url[1].path === 'favorites'; return { query, assetId }; }), shareReplay() @@ -250,9 +255,10 @@ const QUERY_PARAM_MAPPING = { languageItemCodes: 'search[lang]', assetId: 'assetId', workgroupIds: 'search[workgroup]', + categories: 'search[categories]', }; -const updatePlainParam = (params: Params, name: string, value: string | number | undefined): void => { +const updatePlainParam = (params: Params, name: string, value: string | number | boolean | undefined): void => { if (value == null) { delete params[name]; return; diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts index f8b518fe..3e9e5ea6 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts @@ -29,17 +29,7 @@ export interface AppStateWithAssetSearch extends AppState { } const initialState: AssetSearchState = { - query: { - text: undefined, - polygon: undefined, - authorId: undefined, - createDate: undefined, - manCatLabelItemCodes: undefined, - assetKindItemCodes: undefined, - usageCodes: undefined, - geometryCodes: undefined, - languageItemCodes: undefined, - }, + query: {}, results: { page: { size: 0, diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts index c9ac9849..0e4413e6 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts @@ -57,8 +57,10 @@ export const selectAssetSearchTotalResults = createSelector(assetSearchFeature, export const selectCurrentAssetDetail = createSelector(assetSearchFeature, (state) => state.currentAsset); export const selectStudies = createSelector(assetSearchFeature, (state) => state.studies); -export const selectAssetSearchNoActiveFilters = createSelector(assetSearchFeature, ({ query }) => - Object.values(query).every((value) => value === undefined) + +export const selectHasDefaultFilters = createSelector( + assetSearchFeature, + ({ query, currentAsset }) => currentAsset == null && Object.values(query).every((value) => value === undefined) ); export const selectCurrentAssetDetailVM = createSelector( diff --git a/libs/client-shared/src/lib/icons/favourite.ts b/libs/client-shared/src/lib/icons/favorite.ts similarity index 87% rename from libs/client-shared/src/lib/icons/favourite.ts rename to libs/client-shared/src/lib/icons/favorite.ts index 2662e8ec..12b23a53 100644 --- a/libs/client-shared/src/lib/icons/favourite.ts +++ b/libs/client-shared/src/lib/icons/favorite.ts @@ -1,4 +1,4 @@ -export const favouriteIcon = { +export const favoriteIcon = { data: ` @@ -6,5 +6,5 @@ export const favouriteIcon = { `, - name: 'favourite' as const, + name: 'favorite' as const, }; diff --git a/libs/client-shared/src/lib/icons/index.ts b/libs/client-shared/src/lib/icons/index.ts index 67d54ae1..45a0354e 100644 --- a/libs/client-shared/src/lib/icons/index.ts +++ b/libs/client-shared/src/lib/icons/index.ts @@ -15,7 +15,7 @@ import { errorIcon } from './error'; import { errorFilledIcon } from './error-filled'; import { extLinkIcon } from './ext-link'; import { failureIcon } from './failure'; -import { favouriteIcon } from './favourite'; +import { favoriteIcon } from './favorite'; import { helpIcon } from './help'; import { infoIcon } from './info'; import { infoFilledIcon } from './info-filled'; @@ -52,7 +52,7 @@ export const icons = [ errorFilledIcon, extLinkIcon, failureIcon, - favouriteIcon, + favoriteIcon, helpIcon, infoFilledIcon, optionsIcon, diff --git a/libs/client-shared/src/lib/utils/app-matchers.ts b/libs/client-shared/src/lib/utils/app-matchers.ts index 52ff1cf1..f28dc425 100644 --- a/libs/client-shared/src/lib/utils/app-matchers.ts +++ b/libs/client-shared/src/lib/utils/app-matchers.ts @@ -2,7 +2,7 @@ import { UrlMatchResult, UrlSegment } from '@angular/router'; import { isSupportedLang } from '../i18n'; -const validSegments = ['assets', 'favourites']; +const validSegments = ['assets', 'favorites']; export function assetsPageMatcher(segments: UrlSegment[]): UrlMatchResult | null { if (segments.length === 0) { diff --git a/libs/favourite/README.md b/libs/favorites/README.md similarity index 55% rename from libs/favourite/README.md rename to libs/favorites/README.md index a12e0b06..6413092e 100644 --- a/libs/favourite/README.md +++ b/libs/favorites/README.md @@ -1,7 +1,7 @@ -# favourite +# favorites This library was generated with [Nx](https://nx.dev). ## Running unit tests -Run `nx test favourite` to execute the unit tests. +Run `nx test favorites` to execute the unit tests. diff --git a/libs/favourite/eslint.config.js b/libs/favorites/eslint.config.js similarity index 100% rename from libs/favourite/eslint.config.js rename to libs/favorites/eslint.config.js diff --git a/libs/favourite/jest.config.ts b/libs/favorites/jest.config.ts similarity index 88% rename from libs/favourite/jest.config.ts rename to libs/favorites/jest.config.ts index 63bd2c59..e509beb9 100644 --- a/libs/favourite/jest.config.ts +++ b/libs/favorites/jest.config.ts @@ -1,10 +1,10 @@ /* eslint-disable */ export default { - displayName: 'favourite', + displayName: 'favorites', preset: '../../jest.preset.js', setupFilesAfterEnv: ['/src/test-setup.ts'], globals: {}, - coverageDirectory: '../../coverage/libs/favourite', + coverageDirectory: '../../coverage/libs/favorites', transform: { '^.+\\.(ts|mjs|js|html)$': [ 'jest-preset-angular', diff --git a/libs/favourite/ng-package.json b/libs/favorites/ng-package.json similarity index 75% rename from libs/favourite/ng-package.json rename to libs/favorites/ng-package.json index a7fb1483..642364f5 100644 --- a/libs/favourite/ng-package.json +++ b/libs/favorites/ng-package.json @@ -1,6 +1,6 @@ { "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", - "dest": "../../dist/libs/favourite", + "dest": "../../dist/libs/favorites", "lib": { "entryFile": "src/index.ts" } diff --git a/libs/favourite/package.json b/libs/favorites/package.json similarity index 83% rename from libs/favourite/package.json rename to libs/favorites/package.json index 8d292c7a..2d9d7505 100644 --- a/libs/favourite/package.json +++ b/libs/favorites/package.json @@ -1,5 +1,5 @@ { - "name": "@asset-sg/favourite", + "name": "@asset-sg/favorites", "version": "0.0.1", "peerDependencies": { "@angular/common": "^15.0.0", diff --git a/libs/favourite/project.json b/libs/favorites/project.json similarity index 64% rename from libs/favourite/project.json rename to libs/favorites/project.json index 6ef240aa..a8612b85 100644 --- a/libs/favourite/project.json +++ b/libs/favorites/project.json @@ -1,8 +1,8 @@ { - "name": "favourite", + "name": "favorites", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "library", - "sourceRoot": "libs/favourite/src", + "sourceRoot": "libs/favorites/src", "prefix": "asset-sg", "tags": [], "targets": { @@ -10,15 +10,15 @@ "executor": "@nx/angular:ng-packagr-lite", "outputs": ["{workspaceRoot}/dist/{projectRoot}"], "options": { - "project": "libs/favourite/ng-package.json", - "tsConfig": "libs/favourite/tsconfig.lib.json" + "project": "libs/favorites/ng-package.json", + "tsConfig": "libs/favorites/tsconfig.lib.json" }, "configurations": { "production": { - "tsConfig": "libs/favourite/tsconfig.lib.prod.json" + "tsConfig": "libs/favorites/tsconfig.lib.prod.json" }, "development": { - "tsConfig": "libs/favourite/tsconfig.lib.json" + "tsConfig": "libs/favorites/tsconfig.lib.json" } }, "defaultConfiguration": "production" @@ -27,14 +27,14 @@ "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { - "jestConfig": "libs/favourite/jest.config.ts" + "jestConfig": "libs/favorites/jest.config.ts" } }, "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"], "options": { - "eslintConfig": "libs/favourite/eslint.config.js" + "eslintConfig": "libs/favorites/eslint.config.js" } } } diff --git a/libs/favorites/src/index.ts b/libs/favorites/src/index.ts new file mode 100644 index 00000000..86c5524f --- /dev/null +++ b/libs/favorites/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/components/favorite-button/favorite-button.component'; +export * from './lib/favorites.module'; diff --git a/libs/favorites/src/lib/components/favorite-button/favorite-button.component.html b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.html new file mode 100644 index 00000000..c6e49d72 --- /dev/null +++ b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.html @@ -0,0 +1 @@ + diff --git a/libs/favorites/src/lib/components/favorite-button/favorite-button.component.scss b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.scss new file mode 100644 index 00000000..9c272a4b --- /dev/null +++ b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.scss @@ -0,0 +1,20 @@ +:host { + --stroke-color: #295969; + --fill-color: transparent; + + color: var(--stroke-color); + cursor: pointer; +} + +:host > ::ng-deep svg-icon svg { + fill: var(--fill-color); +} + +:host:hover, +:host.is-active:hover { + --fill-color: #{lighten(#295969, 35%)}; +} + +:host.is-active { + --fill-color: var(--stroke-color); +} diff --git a/libs/favorites/src/lib/components/favorite-button/favorite-button.component.ts b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.ts new file mode 100644 index 00000000..9e0bb3f2 --- /dev/null +++ b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.ts @@ -0,0 +1,47 @@ +import { Component, HostBinding, HostListener, inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { AssetId } from '@asset-sg/shared/v2'; +import { Store } from '@ngrx/store'; +import { Subscription } from 'rxjs'; +import { actions, selectFavoriteAssetIds } from '../../state'; + +@Component({ + selector: 'button[asset-sg-favorite]', + templateUrl: './favorite-button.component.html', + styleUrl: './favorite-button.component.scss', +}) +export class FavoriteButtonComponent implements OnInit, OnDestroy { + @Input({ required: true }) + assetId!: number; + + private readonly store = inject(Store); + + private assetIds = new Set(); + + private readonly subscription = new Subscription(); + + @HostBinding('class.is-active') + get isActive(): boolean { + return this.assetIds.has(this.assetId); + } + + @HostListener('click') + handleClick(): void { + if (this.isActive) { + this.store.dispatch(actions.remove({ assetId: this.assetId })); + } else { + this.store.dispatch(actions.add({ assetId: this.assetId })); + } + } + + ngOnInit(): void { + this.subscription.add( + this.store.select(selectFavoriteAssetIds).subscribe((assetIds) => { + this.assetIds = assetIds; + }) + ); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/libs/favorites/src/lib/favorites.module.ts b/libs/favorites/src/lib/favorites.module.ts new file mode 100644 index 00000000..9f14061a --- /dev/null +++ b/libs/favorites/src/lib/favorites.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SvgIconComponent } from '@ngneat/svg-icon'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { FavoriteButtonComponent } from './components/favorite-button/favorite-button.component'; +import { FavoritesEffect } from './state/favorites.effect'; +import { favoritesReducer } from './state/favorites.reducer'; + +@NgModule({ + declarations: [FavoriteButtonComponent], + imports: [ + CommonModule, + StoreModule.forFeature('favorites', favoritesReducer), + EffectsModule.forFeature(FavoritesEffect), + TranslateModule.forChild(), + SvgIconComponent, + ], + providers: [], + exports: [FavoriteButtonComponent], +}) +export class FavoritesModule {} diff --git a/libs/favourite/src/lib/services/favourite.service.spec.ts b/libs/favorites/src/lib/services/favorites.service.spec.ts similarity index 50% rename from libs/favourite/src/lib/services/favourite.service.spec.ts rename to libs/favorites/src/lib/services/favorites.service.spec.ts index 99906ded..4556213c 100644 --- a/libs/favourite/src/lib/services/favourite.service.spec.ts +++ b/libs/favorites/src/lib/services/favorites.service.spec.ts @@ -1,19 +1,20 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { Favourite, FavouriteService } from './favourite.service'; +import { AssetId } from '@asset-sg/shared/v2'; +import { FavoritesService } from './favorites.service'; -describe('FavouriteService', () => { - let service: FavouriteService; +describe(FavoritesService, () => { + let service: FavoritesService; let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [FavouriteService], + providers: [FavoritesService], }); - service = TestBed.inject(FavouriteService); + service = TestBed.inject(FavoritesService); httpMock = TestBed.inject(HttpTestingController); }); @@ -25,16 +26,16 @@ describe('FavouriteService', () => { expect(service).toBeTruthy(); }); - it('should retrieve favourites from API via GET', () => { - const dummyFavourites: Favourite[] = [{}, {}, {}]; + it('should retrieve favorites from API via GET', () => { + const ids: Array = [234, 34, 29]; - service.getFavourites().subscribe((favourites) => { - expect(favourites.length).toBe(3); - expect(favourites).toEqual(dummyFavourites); + service.fetchIds().subscribe((favorites) => { + expect(favorites.length).toBe(3); + expect(favorites).toEqual(ids); }); - const request = httpMock.expectOne(`/api/users/current/favorites`); + const request = httpMock.expectOne(`/api/assets/favorites/ids`); expect(request.request.method).toBe('GET'); - request.flush(dummyFavourites); + request.flush(ids); }); }); diff --git a/libs/favorites/src/lib/services/favorites.service.ts b/libs/favorites/src/lib/services/favorites.service.ts new file mode 100644 index 00000000..29a1dbe1 --- /dev/null +++ b/libs/favorites/src/lib/services/favorites.service.ts @@ -0,0 +1,21 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { AssetId } from '@asset-sg/shared/v2'; +import { map, Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class FavoritesService { + private readonly httpClient = inject(HttpClient); + + fetchIds(): Observable { + return this.httpClient.get(`/api/assets/favorites/ids`); + } + + create(assetId: AssetId): Observable { + return this.httpClient.post(`/api/assets/favorites/${assetId}`, null).pipe(map(() => undefined)); + } + + delete(assetId: AssetId): Observable { + return this.httpClient.delete(`/api/assets/favorites/${assetId}`).pipe(map(() => undefined)); + } +} diff --git a/libs/favorites/src/lib/state/favorites.actions.ts b/libs/favorites/src/lib/state/favorites.actions.ts new file mode 100644 index 00000000..74b4219a --- /dev/null +++ b/libs/favorites/src/lib/state/favorites.actions.ts @@ -0,0 +1,8 @@ +import { AssetId } from '@asset-sg/shared/v2'; +import { createAction, props } from '@ngrx/store'; + +export const initialize = createAction('[Favorites] Initialize'); +export const load = createAction('[Favorites] Load'); +export const set = createAction('[Favorites] Set', props<{ assetIds: AssetId[] }>()); +export const add = createAction('[Favorites] Add', props<{ assetId: AssetId }>()); +export const remove = createAction('[Favorites] Remove', props<{ assetId: AssetId }>()); diff --git a/libs/favorites/src/lib/state/favorites.effect.ts b/libs/favorites/src/lib/state/favorites.effect.ts new file mode 100644 index 00000000..c40a6891 --- /dev/null +++ b/libs/favorites/src/lib/state/favorites.effect.ts @@ -0,0 +1,54 @@ +import { inject, Injectable } from '@angular/core'; +import { Actions, createEffect, ofType, OnInitEffects } from '@ngrx/effects'; +import { Action, Store } from '@ngrx/store'; +import { filter, map, switchMap } from 'rxjs'; +import { FavoritesService } from '../services/favorites.service'; +import * as actions from './favorites.actions'; +import { FavoritesState } from './favorites.reducer'; +import { selectIsInitialized } from './favorites.selector'; + +@Injectable() +export class FavoritesEffect implements OnInitEffects { + private readonly store = inject(Store); + private readonly actions$ = inject(Actions); + private readonly favoritesService = inject(FavoritesService); + + ngrxOnInitEffects(): Action { + return actions.initialize(); + } + + public loadInitial$ = createEffect(() => + this.actions$.pipe( + ofType(actions.initialize), + switchMap(() => this.store.select(selectIsInitialized)), + filter((isInitialized) => !isInitialized), + map(() => actions.load()) + ) + ); + + public load$ = createEffect(() => + this.actions$.pipe( + ofType(actions.load), + switchMap(() => this.favoritesService.fetchIds()), + map((assetIds) => actions.set({ assetIds })) + ) + ); + + public create$ = createEffect( + () => + this.actions$.pipe( + ofType(actions.add), + switchMap(({ assetId }) => this.favoritesService.create(assetId)) + ), + { dispatch: false } + ); + + public delete$ = createEffect( + () => + this.actions$.pipe( + ofType(actions.remove), + switchMap(({ assetId }) => this.favoritesService.delete(assetId)) + ), + { dispatch: false } + ); +} diff --git a/libs/favorites/src/lib/state/favorites.reducer.ts b/libs/favorites/src/lib/state/favorites.reducer.ts new file mode 100644 index 00000000..bdc28e28 --- /dev/null +++ b/libs/favorites/src/lib/state/favorites.reducer.ts @@ -0,0 +1,38 @@ +import { AppState } from '@asset-sg/client-shared'; +import { AssetId } from '@asset-sg/shared/v2'; +import { createReducer, on } from '@ngrx/store'; +import * as actions from './favorites.actions'; + +export interface FavoritesState { + isInitialized: boolean; + assetIds: Set; +} + +const initialState: FavoritesState = { + isInitialized: false, + assetIds: new Set(), +}; + +export interface AppStateWithFavorites extends AppState { + favorites: FavoritesState; +} + +export const favoritesReducer = createReducer( + initialState, + on(actions.set, (state, { assetIds }) => ({ + ...state, + isInitialized: true, + assetIds: new Set(assetIds), + })), + on(actions.add, (state, { assetId }) => { + return { + ...state, + assetIds: new Set([...state.assetIds, assetId]), + }; + }), + on(actions.remove, (state, { assetId }) => { + const assetIds = new Set(state.assetIds); + assetIds.delete(assetId); + return { ...state, assetIds }; + }) +); diff --git a/libs/favorites/src/lib/state/favorites.selector.ts b/libs/favorites/src/lib/state/favorites.selector.ts new file mode 100644 index 00000000..239cd085 --- /dev/null +++ b/libs/favorites/src/lib/state/favorites.selector.ts @@ -0,0 +1,7 @@ +import { createSelector } from '@ngrx/store'; +import { AppStateWithFavorites } from './favorites.reducer'; + +const favoritesFeature = (state: AppStateWithFavorites) => state.favorites; + +export const selectFavoriteAssetIds = createSelector(favoritesFeature, (state) => state.assetIds); +export const selectIsInitialized = createSelector(favoritesFeature, (state) => state.isInitialized); diff --git a/libs/favorites/src/lib/state/index.ts b/libs/favorites/src/lib/state/index.ts new file mode 100644 index 00000000..7a1cdd79 --- /dev/null +++ b/libs/favorites/src/lib/state/index.ts @@ -0,0 +1,3 @@ +export * as actions from './favorites.actions'; +export { FavoritesState, AppStateWithFavorites } from './favorites.reducer'; +export * from './favorites.selector'; diff --git a/libs/favorites/src/lib/styles/_mixins.scss b/libs/favorites/src/lib/styles/_mixins.scss new file mode 100644 index 00000000..5f3c36b1 --- /dev/null +++ b/libs/favorites/src/lib/styles/_mixins.scss @@ -0,0 +1 @@ +@import "../../../../client-shared/src/lib/styles/mixins"; diff --git a/libs/favorites/src/lib/styles/_variables.scss b/libs/favorites/src/lib/styles/_variables.scss new file mode 100644 index 00000000..1e965d1c --- /dev/null +++ b/libs/favorites/src/lib/styles/_variables.scss @@ -0,0 +1 @@ +@import "../../../../client-shared/src/lib/styles/variables"; diff --git a/libs/favourite/src/test-setup.ts b/libs/favorites/src/test-setup.ts similarity index 100% rename from libs/favourite/src/test-setup.ts rename to libs/favorites/src/test-setup.ts diff --git a/libs/favourite/tsconfig.json b/libs/favorites/tsconfig.json similarity index 100% rename from libs/favourite/tsconfig.json rename to libs/favorites/tsconfig.json diff --git a/libs/favourite/tsconfig.lib.json b/libs/favorites/tsconfig.lib.json similarity index 100% rename from libs/favourite/tsconfig.lib.json rename to libs/favorites/tsconfig.lib.json diff --git a/libs/favourite/tsconfig.lib.prod.json b/libs/favorites/tsconfig.lib.prod.json similarity index 100% rename from libs/favourite/tsconfig.lib.prod.json rename to libs/favorites/tsconfig.lib.prod.json diff --git a/libs/favourite/tsconfig.spec.json b/libs/favorites/tsconfig.spec.json similarity index 100% rename from libs/favourite/tsconfig.spec.json rename to libs/favorites/tsconfig.spec.json diff --git a/libs/favourite/src/index.ts b/libs/favourite/src/index.ts deleted file mode 100644 index 2b9fd58f..00000000 --- a/libs/favourite/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './lib/services/favourite.service'; diff --git a/libs/favourite/src/lib/services/favourite.service.ts b/libs/favourite/src/lib/services/favourite.service.ts deleted file mode 100644 index 53c78e5c..00000000 --- a/libs/favourite/src/lib/services/favourite.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface Favourite {} - -@Injectable({ providedIn: 'root' }) -export class FavouriteService { - constructor(private http: HttpClient) {} - - getFavourites(): Observable { - return this.http.get(`/api/users/current/favorites`); - } -} diff --git a/libs/persistence/prisma/migrations/20241028082002_add_favorites/migration.sql b/libs/persistence/prisma/migrations/20241028082002_add_favorites/migration.sql new file mode 100644 index 00000000..ab879582 --- /dev/null +++ b/libs/persistence/prisma/migrations/20241028082002_add_favorites/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the `asset_user_favourite` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "asset_user_favourite" DROP CONSTRAINT "asset_user_favourite_asset_id_fkey"; + +-- DropForeignKey +ALTER TABLE "asset_user_favourite" DROP CONSTRAINT "asset_user_favourite_asset_user_id_fkey"; + +-- DropTable +DROP TABLE "asset_user_favourite"; + +-- CreateTable +CREATE TABLE "favorite" ( + "user_id" UUID NOT NULL, + "asset_id" INTEGER NOT NULL, + + CONSTRAINT "favorite_pkey" PRIMARY KEY ("user_id","asset_id") +); + +-- AddForeignKey +ALTER TABLE "favorite" ADD CONSTRAINT "favorite_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "asset_user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "favorite" ADD CONSTRAINT "favorite_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "asset"("asset_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/libs/persistence/prisma/schema.prisma b/libs/persistence/prisma/schema.prisma index 04557e90..0353d6ca 100644 --- a/libs/persistence/prisma/schema.prisma +++ b/libs/persistence/prisma/schema.prisma @@ -67,7 +67,7 @@ model Asset { assetInternalProjects AssetInternalProject[] subordinateAssets Asset[] @relation("asset") - AssetUserFavourite AssetUserFavourite[] + favorites Favorite[] allStudies AllStudy[] @@map("asset") @@ -636,30 +636,17 @@ model StatusWorkItem { } model AssetUser { - id String @id @db.Uuid - email String - lang String - oidcId String - AssetUserFavourite AssetUserFavourite[] - workgroups WorkgroupsOnUsers[] - isAdmin Boolean @default(false) @map("is_admin") + id String @id @db.Uuid + email String + lang String + oidcId String + favorites Favorite[] + workgroups WorkgroupsOnUsers[] + isAdmin Boolean @default(false) @map("is_admin") @@map("asset_user") } -model AssetUserFavourite { - assetUserId String @map("asset_user_id") @db.Uuid - AssetUser AssetUser @relation(fields: [assetUserId], references: [id], onDelete: Cascade) - - assetId Int @map("asset_id") - Asset Asset @relation(fields: [assetId], references: [assetId], onDelete: Cascade) - - created_at DateTime @db.Timestamptz(6) - - @@id([assetUserId, assetId]) - @@map("asset_user_favourite") -} - model Workgroup { id Int @id @default(autoincrement()) name String @unique @@ -688,6 +675,17 @@ enum Role { MasterEditor @map("master-editor") } +model Favorite { + userId String @map("user_id") @db.Uuid + user AssetUser @relation(fields: [userId], references: [id]) + + assetId Int @map("asset_id") + Asset Asset @relation(fields: [assetId], references: [assetId]) + + @@id([userId, assetId]) + @@map("favorite") +} + view AllStudy { assetId Int @map("asset_id") asset Asset @relation(fields: [assetId], references: [assetId], onDelete: Cascade) diff --git a/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts b/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts index 9f5d360a..15ee0ab4 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts @@ -1,5 +1,5 @@ import { Type } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; import { PartialDateRangeDTO } from '../date-range.dto'; import { UsageCode } from '../usage'; @@ -42,6 +42,10 @@ export class AssetSearchQueryDTO implements AssetSearchQuery { @IsOptional() workgroupIds?: number[]; + @IsBoolean() + @IsOptional() + favoritesOnly?: boolean; + @IsOptional() @ValidateNested() @Type(() => PartialDateRangeDTO) diff --git a/libs/shared/src/lib/models/asset-search/asset-search-query.ts b/libs/shared/src/lib/models/asset-search/asset-search-query.ts index 38e59060..4e55d4a8 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-query.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-query.ts @@ -13,6 +13,7 @@ export interface AssetSearchQuery { geometryCodes?: Array; languageItemCodes?: string[]; workgroupIds?: number[]; + favoritesOnly?: boolean; } export enum GeometryCode { diff --git a/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts b/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts index cd1696a2..ad08e45b 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts @@ -21,6 +21,7 @@ export class AssetSearchStatsDTO implements AssetSearchStats { manCatLabelItemCodes!: ValueCount[]; usageCodes!: ValueCount[]; workgroupIds!: ValueCount[]; + categories!: ValueCount<'favorites'>[]; @Type(() => DateRangeDTO) createDate!: DateRangeDTO | null; diff --git a/libs/shared/src/lib/models/elastic-search-asset.ts b/libs/shared/src/lib/models/elastic-search-asset.ts index be654d1e..d1dbf1d5 100644 --- a/libs/shared/src/lib/models/elastic-search-asset.ts +++ b/libs/shared/src/lib/models/elastic-search-asset.ts @@ -18,6 +18,7 @@ export interface ElasticSearchAsset { geometryCodes: GeometryCode[] | ['None']; studyLocations: ElasticPoint[]; workgroupId: number; + favoredByUserIds: string[]; data: SerializedAssetEditDetail; } diff --git a/libs/shared/v2/src/index.ts b/libs/shared/v2/src/index.ts index c9036f77..8f23ea4d 100644 --- a/libs/shared/v2/src/index.ts +++ b/libs/shared/v2/src/index.ts @@ -12,6 +12,7 @@ export * from './lib/policies/base/policy'; export * from './lib/policies/asset.policy'; export * from './lib/policies/asset-edit.policy'; export * from './lib/policies/contact.policy'; +export * from './lib/policies/favorite.policy'; export * from './lib/policies/workgroup.policy'; export * from './lib/schemas/base/schema'; diff --git a/libs/shared/v2/src/lib/models/asset.fake.ts b/libs/shared/v2/src/lib/models/asset.fake.ts new file mode 100644 index 00000000..9df31b03 --- /dev/null +++ b/libs/shared/v2/src/lib/models/asset.fake.ts @@ -0,0 +1,33 @@ +import { fakerDE_CH as faker } from '@faker-js/faker'; + +// @ts-expect-error: import file from outside rootDir +import { fakeAssetFormatItemCode } from './/test/data/asset-format-item'; +// @ts-expect-error: import file from outside rootDir +import { fakeContactKindItemCode } from './/test/data/contact-kind-item'; +import { AssetInfo } from './asset'; +import { LocalDate } from './base/local-date'; + +let nextUniqueId = 0; +const fakeIdNumber = (): number => nextUniqueId++; + +export const fakeAssetInfo = (): AssetInfo => ({ + id: fakeIdNumber(), + title: faker.commerce.product(), + originalTitle: faker.commerce.product(), + kindCode: fakeContactKindItemCode(), + formatCode: fakeAssetFormatItemCode(), + identifiers: [], + languageCodes: [], + contactAssignments: [], + manCatLabelCodes: [], + natRelCodes: [], + links: { + parent: null, + children: [], + siblings: [], + }, + files: [], + createdAt: LocalDate.fromDate(faker.date.past()), + receivedAt: LocalDate.fromDate(faker.date.past()), + lastProcessedAt: faker.date.past(), +}); diff --git a/libs/shared/v2/src/lib/policies/base/policy.ts b/libs/shared/v2/src/lib/policies/base/policy.ts index ed748851..c1c86040 100644 --- a/libs/shared/v2/src/lib/policies/base/policy.ts +++ b/libs/shared/v2/src/lib/policies/base/policy.ts @@ -46,7 +46,7 @@ export abstract class Policy { return this.canCreate(); } - canDelete(_value: T): boolean { - return this.canCreate(); + canDelete(value: T): boolean { + return this.canUpdate(value); } } diff --git a/libs/shared/v2/src/lib/policies/favorite.policy.ts b/libs/shared/v2/src/lib/policies/favorite.policy.ts new file mode 100644 index 00000000..fed71c61 --- /dev/null +++ b/libs/shared/v2/src/lib/policies/favorite.policy.ts @@ -0,0 +1,18 @@ +import { Favorite } from '../models/favorite'; +import { Policy } from './base/policy'; + +export class FavoritePolicy extends Policy { + canShow(value: Favorite): boolean { + // A user can see their own favorites. + return value.userId == this.user.id; + } + + override canCreate(): boolean { + // Every user can add to their own favorites. + return true; + } + + override canUpdate(value: Favorite): boolean { + return this.canShow(value); + } +} diff --git a/libs/shared/v2/src/test-helpers.ts b/libs/shared/v2/src/test-helpers.ts new file mode 100644 index 00000000..e9e06f80 --- /dev/null +++ b/libs/shared/v2/src/test-helpers.ts @@ -0,0 +1 @@ +export * from './lib/models/asset.fake'; diff --git a/test/data/contact-kind-item.ts b/test/data/contact-kind-item.ts index 50225b02..fe39fdb3 100644 --- a/test/data/contact-kind-item.ts +++ b/test/data/contact-kind-item.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; -export const fakeContactKindItem = (): string => +export const fakeContactKindItemCode = (): string => faker.helpers.arrayElement(contactKindItems.map((item) => item.contactKindItemCode)); export const contactKindItems = [ diff --git a/tsconfig.base.json b/tsconfig.base.json index a05295c2..c6817194 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -22,10 +22,11 @@ "@asset-sg/auth": ["libs/auth/src/index.ts"], "@asset-sg/client-shared": ["libs/client-shared/src/index.ts"], "@asset-sg/core": ["libs/core/src/index.ts"], - "@asset-sg/favourite": ["libs/favourite/src/index.ts"], + "@asset-sg/favorites": ["libs/favorites/src/index.ts"], "@asset-sg/profile": ["libs/profile/src/index.ts"], "@asset-sg/shared": ["libs/shared/src/index.ts"], "@asset-sg/shared/v2": ["libs/shared/v2/src/index.ts"], + "@asset-sg/shared/v2/test-helpers": ["libs/shared/v2/src/test-helpers.ts"], "ngx-kobalte": ["libs/ngx-kobalte/src/index.ts"], "persistence": ["libs/persistence/src/index.ts"] }