# Migrating `isDraft` to a `DRAFT` tag Follows the precedent set by `Account.isExcluded` → `EXCLUDE_FROM_ANALYSIS`, which retired that column in two releases: [`79e382a8f`](https://github.com/ghostfolio/ghostfolio/commit/79e382a8f) made the tag fully functional and deprecated the column, and [`263e064fd`](https://github.com/ghostfolio/ghostfolio/commit/263e064fd) removed it in a single migration that backfilled and dropped in one step. **Status:** release 1 is implemented. Release 2 is the remaining work. ## Motivation `Order.isDraft` was a persisted snapshot of "the date is in the future", computed at write time in three places that did not agree: | Site | Definition | | ------------------------- | ------------------------------------------------------------ | | `createActivity` | `FEE/INTEREST/LIABILITY → false`, else `date > endOfToday()` | | `updateActivity` | same, **plus** `MANUAL` + `BUY → false` | | `previewImport` (dry run) | `date > endOfToday()`, no type carve-out | The `MANUAL` + `BUY` carve-out was accidental. The `if` it lived in is about whether `SymbolProfile.name` is editable (custom asset profiles), and the `isDraft` computation merely sat inside its `else`. A custom asset you plan to buy next month is a legitimate draft, so this carve-out is gone. Nothing ever recomputed the column. No job flipped a stored `isDraft = true` once the date passed, while `getActivities` defaults to `includeDrafts: false`. A future-dated `BUY` was therefore excluded from the portfolio **permanently**, not just until its date arrived. The goal is that the user, not the system, owns this state after creation. A flag recomputed on every write cannot express that; a tag can. ## The rule > The **Draft** tag is added when an activity's date _newly becomes_ a future date, unless > its type is FEE, INTEREST or LIABILITY. It is never removed automatically. The user may > add or remove it freely, on future and past activities alike. "Newly becomes" is what makes this one rule rather than separate create and update cases: - **create / import** — `date > endOfToday()` - **update** — `date > endOfToday() && originalDate <= endOfToday()` The original-date comparison is essential. Without it, correcting a fee on a future-dated activity the user deliberately untagged would silently re-tag it, undoing their choice. Two consequences of keying the rule on the date transition alone, both intentional: - **Changing only the type does not assign the tag.** A future-dated `FEE` edited into a `BUY` keeps its date, so `originalDate` is already in the future and the rule declines. The old code recomputed from the date and would have marked it a draft. Widening the rule to the type transition would mean re-tagging activities the user untagged, which is the behaviour the original-date comparison exists to prevent. - **An update that does not send `tags` leaves them untouched.** `tags` is optional in `UpdateOrderDto`, and treating an absent list as an empty one would let a partial update drop the tag with no way for the rule to restore it. Such an update still gains the tag when its date newly moves into the future, via `connect` rather than `set`. ## Semantics `DRAFT` is **stronger** than `EXCLUDE_FROM_ANALYSIS`, not equivalent to it: | | Appears in portfolio analysis | Appears in "Excluded" summary | Counted in `activitiesCount` | | ----------------------- | ----------------------------- | ----------------------------- | ---------------------------- | | normal | yes | no | yes | | `EXCLUDE_FROM_ANALYSIS` | no | yes | yes | | `DRAFT` | no | no | **yes** | The guiding principle: **record counts include drafts, money aggregates do not.** `activitiesCount` answers "how many records are in this account" — a draft is a record. `totalOfExcludedActivities` answers "how much money" — a draft has not happened. Counting drafts also closes a hole in a delete guard. [accounts-table.component.html:333](../libs/ui/src/lib/accounts-table/accounts-table.component.html#L333) disables the **Delete account** button via `element.activitiesCount > 0`. An account holding only drafts reported `0` before, so Delete was enabled; [deleteAccount](../apps/api/src/app/account/account.service.ts#L214) calls `account.delete()` with no guard, and `Order.account` is an optional relation with no `onDelete` ([schema.prisma:177](../prisma/schema.prisma#L177)), so Prisma's default `SetNull` silently detached those activities. It also reconciles two counts that disagreed: `user.activitiesCount` is a raw `_count.activities` ([user.service.ts:316](../apps/api/src/app/user/user.service.ts#L316)) and already included drafts, while the per-account counts did not. The two tags therefore stay **independent predicates** — do not group them into a shared "excluding tags" set. `includeDrafts` survives as a `getActivities` parameter, simply re-implemented as a tag predicate instead of `where.isDraft = false`. This was already the behaviour before the migration: `getSummary` omits `includeDrafts`, so it defaults to `false` and drafts are dropped at the DB before the excluded / non-excluded split runs. **Data gathering stays derived from the date, never from a tag.** There is no market price to fetch for a date that has not happened, so no tag decision may reach that code path. This is the one piece of `isDraft` that must _not_ become user-owned. ## `DRAFT` is per-activity, never per-collection `DRAFT` is the first system tag that qualifies a single record rather than a container, so every path that assigns tags to a _set_ of records has to refuse it. There are three: | Path | Target | Guard | | ------------------------------------------------ | --------------------------- | ------------------------------------ | | `accountService.createAccount` / `updateAccount` | an account | `validateTagIdsWithoutDraftTag` | | `activitiesService.assignTags` | every activity of a holding | `validateTagIdsWithoutDraftTag` | | `activitiesService.updateActivity` | one activity | none — this is where `DRAFT` belongs | `tagService.validateTagIds` only checks ownership, and system tags have `userId = null`, so it accepts `DRAFT` by design. `validateTagIdsWithoutDraftTag` wraps it with the rejection and is what the first two paths call. `assignTags` needs more than a guard. It writes `tags: { set: [...] }` over every activity of a holding, so even with `DRAFT` rejected on input the `set` would drop a tag an individual activity already carries. It therefore loads each activity's tags and carries `DRAFT` over per row. This is also the write path most easily forgotten — it is the third place the deprecated column has to be mirrored, and the reason the mirror is worth keeping in mind at every `tags` write rather than only at the two obvious ones. Both client dialogs that feed these paths filter `DRAFT` out of `tagsAvailable`: [create-or-update-account-dialog.component.ts](../apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts) and [holding-detail-dialog.component.ts](../apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts). ## Release 1 — introduce the tag, deprecate `isDraft` (done) The tag is the single source of truth for every read. The column stays in the database as an exact mirror of the tag, deprecated and unread, so that release 2 is a pure deletion. ### 1 — Introduce the tag - `TAG_ID_DRAFT` in [config.ts](../libs/common/src/lib/config.ts), added to `TAG_IDS_SYSTEM` - ``DRAFT: $localize`Draft` `` in [i18n.ts](../libs/ui/src/lib/i18n.ts) - entry in [seed.mts](../prisma/seed.mts) The migration creates the `Tag` row and backfills `_OrderToTag` from `isDraft = true`. It cannot be left to the seed even though `seed.mts` uses `createMany({ skipDuplicates: true })` and runs on every start — [docker/entrypoint.sh](../docker/entrypoint.sh) runs `prisma migrate deploy` _before_ `prisma db seed`, so the backfill's foreign key has to create its own target. > **Why the backfill ships here and not with the drop.** Reads move to the tag in this same > release. Without the backfill, every existing `isDraft = true` row would read as _not_ a > draft the moment it deploys, and a future-dated `BUY` would re-enter the portfolio as a > holding the user does not own yet. `isExcluded` could defer its backfill because its > release 1 read `isExcluded || tag`; this migration reads the tag alone, so the data has to > be there first. Step 6 is what makes that safe to revert. Rename and delete guarding in the admin control panel comes free — [tags.controller.ts:73](../apps/api/src/app/endpoints/tags/tags.controller.ts#L73) already rejects anything `isSystemTag` matches. ### 2 — Make it activity-only See [`DRAFT` is per-activity, never per-collection](#draft-is-per-activity-never-per-collection) above. ### 3 — Helpers Mirroring [account.helper.ts](../apps/api/src/helper/account.helper.ts) field for field: - `isDraftActivity({ tags })` in [helper.ts](../libs/common/src/lib/helper.ts), next to `isAccountExcluded` — the shared read predicate - `WHERE_ACTIVITY_NOT_DRAFT` in [activity.helper.ts](../apps/api/src/helper/activity.helper.ts), next to `WHERE_ACCOUNT_NOT_EXCLUDED` — the Prisma equivalent - `isActivityInFuture({ date })`, next to `isAccountBalanceInFuture` — the date predicate that keeps data gathering off the tag - `isDraftTagToBeAssigned({ date, originalDate, type })` — the transition rule above - `getTagsWithDraftTag({ date, draftTag, originalDate, tags, type })` — applies that rule to a tag list. Generic over the tag shape, because the write paths pass `{ id }` while the import preview needs the full `Tag` for its response Both date-dependent helpers take an injectable `endOfTodayDate` so the rule is testable without freezing the clock; see [activity.helper.spec.ts](../apps/api/src/helper/activity.helper.spec.ts). ### 4 — Auto-assign on write Apply the transition rule via `getTagsWithDraftTag` in `createActivity`, `updateActivity` and the import dry run. `updateActivity` needs the stored date to evaluate the rule. The controller already loads the activity as `originalActivity` to authorize the request ([activities.controller.ts:317](../apps/api/src/app/activities/activities.controller.ts#L317)), so it passes the date down rather than the service issuing a second query. The import dry run resolves the `Tag` row from the user's tag list once, above the loop, and falls back to the compile-time constant. Degrading to "no tag" on a lookup miss would make the preview disagree with what the real run writes, which is the divergence this step removes. ### 5 — Switch reads to the tag The DB filter in `getActivities` becomes `WHERE_ACTIVITY_NOT_DRAFT`. > **Gotcha 1:** `where.tags` and `where.OR` are already assigned further down in > `getActivities`. A second assignment silently clobbers the first, so this must go through > the existing `andConditions` array. > **Gotcha 2:** `includeDrafts` defaults to `false`, so pushing the exclusion > unconditionally makes the tag unfilterable — the tag filter pushes `tags: { some: DRAFT }` > into the same `andConditions`, and the two contradict. The exclusion is skipped when > `filtersByTag` contains `TAG_ID_DRAFT`. Without this, step 7's "DRAFT stays visible in the > filter list" yields an empty portfolio rather than the drafts. Field reads converted: - `isDraftActivity` in [activities-table.component.ts](../libs/ui/src/lib/activities-table/activities-table.component.ts), exposed to the template as a public field for the badge and the ICS gate - `getSumOfActivityType` in [portfolio.service.ts](../apps/api/src/app/portfolio/portfolio.service.ts) The two gather gates ([activities.controller.ts:291](../apps/api/src/app/activities/activities.controller.ts#L291) and the `else` branch of `updateActivity`) convert to `isActivityInFuture` instead — they are date questions, not tag questions. In the two count loops the guard **moves** rather than disappears — off the record count, onto the money sums: - [account.service.ts:245](../apps/api/src/app/account/account.service.ts#L245) — count only, so the loop collapses to `activitiesCount = account.activities.length`. Its `include` needs nothing added. - [portfolio.service.ts:216](../apps/api/src/app/portfolio/portfolio.service.ts#L216) — the count becomes unconditional, and the `DIVIDEND` / `INTEREST` cases gain the draft guard the count gives up (see below). This loop reads raw Prisma rows, so its `include` needs `tags` added to `activities` — the existing `tags: true` there is the _account's_ tags, not the activities'. Narrowed to `where: { id: TAG_ID_DRAFT }, select: { id: true }`, since the loop only asks one yes/no question and the alternative materialises every tag row of every activity on each request. #### Fixing the dividend and interest sums That loop iterated an unfiltered `include` and gated only the count on `isDraft`. `dividendInBaseCurrency` and `interestInBaseCurrency` were not gated, so a future-dated `DIVIDEND` contributed money the user has not received. Both sums move behind the draft check, which is what makes the record-vs-money principle hold in both directions. `INTEREST` is exempt from auto-assignment, so the guard there only bites when the user tags an interest activity by hand — applied anyway for consistency. ### 6 — Reduce `isDraft` to a mirror The column is no longer computed from the date at any write site. It is written as `isDraftActivity({ tags })` over the tag list the same statement persists, which makes it exact rather than merely close. **Four** statements write it: | Site | Mirror | | ------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `createActivity` | `isDraftActivity({ tags: tagsToConnect })` | | `updateActivity` | `isDraftActivity({ tags: tagsToSet })`, or `true` when the date newly moves into the future and no tags were sent | | `assignTags` | the activity's own draft state, carried over | | `previewImport` (dry run) | `isDraftActivity({ tags: previewTags })` — response only, nothing persisted | This is what carries the release. It keeps the deprecated field truthful for API consumers during the deprecation window, and it makes release 1 revertible by deploy rather than by database restore: roll the code back and the column is still correct for every row, because every write since the deploy mirrored the tag and the backfill covered everything before it. A single tag write that skips the mirror breaks that guarantee for the rows it touches, which is why the table above is exhaustive rather than illustrative. Lifting the computation out of the profile-editability `else` in `updateActivity` is what un-exempts `MANUAL` + `BUY`. That is safe now precisely because nothing reads the column. Marked in [schema.prisma](../prisma/schema.prisma), as `isExcluded` was: ```prisma /// @deprecated Use the "Draft" tag (`TAG_ID_DRAFT`) instead isDraft Boolean @default(false) ``` ### 7 — Visibility - [user.service.ts:198](../apps/api/src/app/user/user.service.ts#L198) narrowed `user.tags` to _only_ `EXCLUDE_FROM_ANALYSIS` for Basic subscribers. `DRAFT` is added, or Basic users get auto-tagged drafts they cannot untag. - `DRAFT` **stays visible** in the portfolio filter list. The exclusion of `EXCLUDE_FROM_ANALYSIS` at [portfolio-filter-form.util.ts:110](../libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts#L110) is not extended to it — filtering for uncertain activities is the point of the tag. Leaving that list alone is necessary but not sufficient; see gotcha 2 in step 5. ### 8 — Changelog ```markdown ### Added - Added the _Draft_ tag, assigned automatically to activities dated in the future ### Changed - Deprecated the `isDraft` attribute of the activity in favor of the _Draft_ tag - Changed the activities count of an account to include draft activities - Extended the _Draft_ tag to activities with a custom asset profile of type `BUY` ### Fixed - Fixed the dividend and interest of an account by excluding draft activities ``` ## Release 2 — remove `isDraft` Ships once release 1 has been out and proven. The data moved in release 1, so this migration only drops: ```sql -- DropIndex DROP INDEX "Order_isDraft_idx"; -- AlterTable ALTER TABLE "Order" DROP COLUMN "isDraft"; ``` Keeping the drop in its own release is the entire point of the split: it is the one step that cannot be undone by a deploy. Then remove the field: - `isDraft` and `@@index([isDraft])` from [schema.prisma](../prisma/schema.prisma) — the index has been dead since release 1 moved the predicate onto `_OrderToTag`, but it is dropped here with the column rather than separately - the four mirror writes listed in release 1 step 6 - `Omit` list in [export-response.interface.ts:18](../libs/common/src/lib/interfaces/responses/export-response.interface.ts#L18) - [portfolio-calculator-test-utils.ts:15](../apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts#L15) - the synthetic `isDraft: false` literals at [activities.service.ts:545](../apps/api/src/app/activities/activities.service.ts#L545) and [import.service.ts:171](../apps/api/src/app/import/import.service.ts#L171) - 5 occurrences in [activities-table.component.stories.ts](../libs/ui/src/lib/activities-table/activities-table.component.stories.ts) `git grep -n isDraft` should come back empty apart from the historical migrations under `prisma/migrations`, which are never edited. `Activity extends Order` loses `isDraft` with the schema. It is dropped from the response rather than recomputed — the client already receives `tags` and already derives `isExcludedFromAnalysis` that way, so deriving `isDraft` identically is the consistent end state. Changelog, following the `isExcluded` precedent: ```markdown ### Changed - Removed the deprecated `isDraft` attribute of the activity in favor of the _Draft_ tag including a data migration ``` ## Follow-ups, not part of either release Found while migrating, unrelated to the column: - **`getAccounts` materialises every activity to take its `.length`.** [account.service.ts](../apps/api/src/app/account/account.service.ts) uses `include: { activities: true }` and, since release 1 collapsed the loop, reads nothing but the array length before `delete result.activities`. `_count: { select: { activities: true } }` returns the same number from the database. - **`hasDrafts` in [activities-table.component.ts:143](../libs/ui/src/lib/activities-table/activities-table.component.ts#L143) is never assigned.** It has no `@Input` and no writer, yet gates the bulk _Export Drafts as ICS_ action, which is therefore permanently disabled. Predates this migration; `hasDrafts = data.some(isDraftActivity)` now fixes it. ## Behavioural changes to call out in the pull request **Every existing draft becomes visible and removable.** Nothing recomputed the column, so a future-dated activity whose date has since passed was excluded from the portfolio **permanently**. After the backfill those rows carry the _Draft_ tag and are still excluded — but the user can now see why, and remove it. That is a fix, though it will look like a change to anyone affected. **`activitiesCount` starts including drafts**, which changes a visible number in the accounts table and enables the **Delete account** guard for accounts holding only drafts. **A `MANUAL` + `BUY` activity can now become a draft**, where the accidental carve-out previously prevented it on update.