Browse Source

Setup draft tag

pull/7551/head
Thomas Kaul 3 weeks ago
parent
commit
251e51a9a5
  1. 4
      apps/api/src/app/account/account.service.ts
  2. 26
      apps/api/src/app/activities/activities.service.ts
  3. 3
      apps/api/src/app/user/user.service.ts
  4. 32
      apps/api/src/helper/activity.helper.ts
  5. 20
      apps/api/src/services/tag/tag.service.ts
  6. 19
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  7. 2
      libs/common/src/lib/config.ts
  8. 9
      libs/common/src/lib/helper.ts
  9. 1
      libs/ui/src/lib/i18n.ts
  10. 13
      prisma/migrations/20260805130140_added_draft_tag_to_order/migration.sql
  11. 4
      prisma/seed.mts

4
apps/api/src/app/account/account.service.ts

@ -172,7 +172,7 @@ export class AccountService {
tagIds?: string[]; tagIds?: string[];
userId: string; userId: string;
}): Promise<Account> { }): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId }); await this.tagService.validateTagIdsForAccount({ tagIds, userId });
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data: { data: {
@ -317,7 +317,7 @@ export class AccountService {
userId: string; userId: string;
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> { }): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId }); await this.tagService.validateTagIdsForAccount({ tagIds, userId });
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data: { data: {

26
apps/api/src/app/activities/activities.service.ts

@ -7,6 +7,7 @@ import {
isAccountBalanceInFuture, isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper'; } from '@ghostfolio/api/helper/account.helper';
import { isDraftTagToBeAssigned } from '@ghostfolio/api/helper/activity.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -21,6 +22,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
NON_INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -265,13 +267,20 @@ export class ActivitiesService {
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
const tagsToConnect = isDraftTagToBeAssigned({
date: data.date as Date,
type: data.type
})
? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id')
: tags;
const activity = await this.prismaService.order.create({ const activity = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
account, account,
isDraft, isDraft,
tags: { tags: {
connect: tags connect: tagsToConnect
} }
}, },
include: { SymbolProfile: true } include: { SymbolProfile: true }
@ -1014,13 +1023,26 @@ export class ActivitiesService {
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
const storedActivity = await this.prismaService.order.findUnique({
where,
select: { date: true }
});
const tagsToSet = isDraftTagToBeAssigned({
date: data.date as Date,
storedDate: storedActivity?.date,
type: data.type
})
? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id')
: tags;
const activity = await this.prismaService.order.update({ const activity = await this.prismaService.order.update({
where, where,
data: { data: {
...data, ...data,
isDraft, isDraft,
tags: { tags: {
set: tags set: tagsToSet
} }
} }
}); });

3
apps/api/src/app/user/user.service.ts

@ -35,6 +35,7 @@ import {
PROPERTY_MAX_DAILY_REQUESTS, PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY, THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL THROTTLE_DAILY_TTL
@ -195,7 +196,7 @@ export class UserService {
subscription.type === SubscriptionType.Basic subscription.type === SubscriptionType.Basic
) { ) {
tags = tags.filter(({ id }) => { tags = tags.filter(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; return [TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS].includes(id);
}); });
} }

32
apps/api/src/helper/activity.helper.ts

@ -0,0 +1,32 @@
import { Type as ActivityType } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
const ACTIVITY_TYPES_NEVER_DRAFT: ActivityType[] = [
'FEE',
'INTEREST',
'LIABILITY'
];
export function isDraftTagToBeAssigned({
date,
endOfTodayDate = endOfToday(),
storedDate,
type
}: {
date: Date;
endOfTodayDate?: Date;
storedDate?: Date;
type: ActivityType;
}) {
if (ACTIVITY_TYPES_NEVER_DRAFT.includes(type)) {
return false;
}
if (!isAfter(date, endOfTodayDate)) {
return false;
}
// Assign only when the date newly moves into the future, so that a tag the
// user has removed is not restored by an unrelated change
return storedDate ? !isAfter(storedDate, endOfTodayDate) : true;
}

20
apps/api/src/services/tag/tag.service.ts

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client'; import { Prisma, Tag } from '@prisma/client';
@ -160,4 +161,23 @@ export class TagService {
); );
} }
} }
public async validateTagIdsForAccount({
tagIds,
userId
}: {
tagIds: string[];
userId: string;
}) {
// The "DRAFT" tag qualifies an individual activity and cannot be assigned
// to an account
if (tagIds?.includes(TAG_ID_DRAFT)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.validateTagIds({ tagIds, userId });
}
} }

19
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper'; import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -92,12 +93,18 @@ export class GfCreateOrUpdateAccountDialogComponent {
); );
this.tagsAvailable = this.tagsAvailable =
this.data.user?.tags?.map((tag) => { this.data.user?.tags
return { ?.filter(({ id }) => {
...tag, // The "DRAFT" tag qualifies an individual activity and cannot be
name: translate(tag.name) // assigned to an account
}; return id !== TAG_ID_DRAFT;
}) ?? []; })
.map((tag) => {
return {
...tag,
name: translate(tag.name)
};
}) ?? [];
this.accountForm = this.formBuilder.group({ this.accountForm = this.formBuilder.group({
accountId: [{ disabled: true, value: this.data.account.id }], accountId: [{ disabled: true, value: this.data.account.id }],

2
libs/common/src/lib/config.ts

@ -343,12 +343,14 @@ export const SUPPORTED_LANGUAGE_CODES = [
] as const; ] as const;
export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874';
export const TAG_ID_DRAFT = '0c077abd-eca2-4cbb-818c-6cefbf2d169a';
export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180'; export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180';
export const TAG_ID_EXCLUDE_FROM_ANALYSIS = export const TAG_ID_EXCLUDE_FROM_ANALYSIS =
'f2e868af-8333-459f-b161-cbc6544c24bd'; 'f2e868af-8333-459f-b161-cbc6544c24bd';
export const TAG_IDS_SYSTEM = [ export const TAG_IDS_SYSTEM = [
TAG_ID_DEMO, TAG_ID_DEMO,
TAG_ID_DRAFT,
TAG_ID_EMERGENCY_FUND, TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
]; ];

9
libs/common/src/lib/helper.ts

@ -43,6 +43,7 @@ import {
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioPrefix, ghostfolioPrefix,
SEARCH_QUERY_MINIMUM_LENGTH, SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
TAG_IDS_SYSTEM TAG_IDS_SYSTEM
} from './config'; } from './config';
@ -537,6 +538,14 @@ export function isDerivedCurrency(aCurrency: string) {
}); });
} }
export function isDraftActivity(activity?: { tags?: { id: string }[] }) {
return (
activity?.tags?.some(({ id }) => {
return id === TAG_ID_DRAFT;
}) === true
);
}
export function isRootCurrency(aCurrency: string) { export function isRootCurrency(aCurrency: string) {
if (aCurrency === 'USD') { if (aCurrency === 'USD') {
return true; return true;

1
libs/ui/src/lib/i18n.ts

@ -18,6 +18,7 @@ const locales = {
DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`, DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`, DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`,
DATA_SOURCE: $localize`Data Source`, DATA_SOURCE: $localize`Data Source`,
DRAFT: $localize`Draft`,
EMERGENCY_FUND: $localize`Emergency Fund`, EMERGENCY_FUND: $localize`Emergency Fund`,
EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`, EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`,
Global: $localize`Global`, Global: $localize`Global`,

13
prisma/migrations/20260805130140_added_draft_tag_to_order/migration.sql

@ -0,0 +1,13 @@
-- Create the "DRAFT" tag if it does not exist yet
INSERT INTO "Tag" ("id", "name")
VALUES ('0c077abd-eca2-4cbb-818c-6cefbf2d169a', 'DRAFT')
ON CONFLICT DO NOTHING;
-- Migrate activities with "isDraft" to the "DRAFT" tag
INSERT INTO "_OrderToTag" ("A", "B")
SELECT
"id",
'0c077abd-eca2-4cbb-818c-6cefbf2d169a'
FROM "Order"
WHERE "isDraft" = true
ON CONFLICT DO NOTHING;

4
prisma/seed.mts

@ -10,6 +10,10 @@ const prisma = new PrismaClient({ adapter });
async function main() { async function main() {
await prisma.tag.createMany({ await prisma.tag.createMany({
data: [ data: [
{
id: '0c077abd-eca2-4cbb-818c-6cefbf2d169a',
name: 'DRAFT'
},
{ {
id: '4452656d-9fa4-4bd0-ba38-70492e31d180', id: '4452656d-9fa4-4bd0-ba38-70492e31d180',
name: 'EMERGENCY_FUND' name: 'EMERGENCY_FUND'

Loading…
Cancel
Save