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.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 }}:
-