Files
CRM_Montzar/docs/superpowers/plans/2026-10-17-prior-data-archive-cutoff.md
T

21 KiB

Prior Archive Cutoff Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Split deals/activities into current vs archive by an org-wide Jalali cutoff date, with a one-way promote tag that never mutates other fields.

Architecture: Reuse Organization.priorArchiveEnabledAt as the cutoff (nullable = no filter). Add promotedFromPriorAt on Deal and Activity. Rewrite prior-archive.ts to filter only by createdAt + promote tag (drop Didar/importSource and the open-future exception). Expose cutoff on auth + org APIs; owner sets date and multi-selects users in Teams settings; promote action lives in the existing three-dot menus when lens is archive.

Tech Stack: NestJS, Prisma, node:test, @mento/shared, Vue 3, existing JalaliDateInput, UiDropdownMenu.

Spec: docs/superpowers/specs/2026-10-17-prior-data-archive-cutoff-design.md

Global Constraints

  • npm workspaces only (npm run … -w @mento/…); never pnpm/yarn
  • Prisma only from apps/api (needs apps/api/.env)
  • Shell is PowerShell: use ; not &&
  • Transfer never changes createdAt or any field except promotedFromPriorAt
  • Filter applies only to deals and activities; people/leads/rules/etc. stay unfiltered
  • Without user prior-archive grant: no switch, no filter (full environment)
  • Without cutoff date: no filter even if user is granted
  • After code edits: graphify update . (may crash; non-blocking)
  • Do not start implementation commits unless the user asked to implement / commit

File Map

File Responsibility
apps/api/prisma/schema.prisma promotedFromPriorAt on Deal/Activity; comment on org cutoff
apps/api/prisma/migrations/* Migration
apps/api/src/common/prior-archive.ts Pure where builders for deal/activity
apps/api/test/prior-archive.test.ts Unit tests for new rules
packages/shared/src/index.ts DTO fields: cutoff, promote flag, auth priorArchive shape
apps/api/src/auth/jwt.strategy.ts priorCutoff only when grant + cutoff present
apps/api/src/auth/auth.service.ts Expose priorArchive.cutoffAt
apps/api/src/billing/billing.service.ts Stop auto-stamping cutoff on module enable
apps/api/src/organizations/* Owner PATCH cutoff date
apps/api/src/pipelines/* + activities/* Use new where; promote endpoints; DTO mapping
apps/api/src/contacts/people.service.ts Remove person prior filter
apps/api/src/contacts/companies.service.ts Remove company prior filter
apps/api/src/reports/reports.service.ts Drop person prior; pass cutoff to deal/activity
apps/api/src/ai/ai-ask.service.ts Same
apps/web/src/components/TeamsSettingsPanel.vue Date next to h3 + multi-select users
apps/web/src/views/ActivitiesView.vue Promote in three-dot menu
apps/web/src/views/PipelineBoardView.vue Promote in deal menu-pop
apps/web/src/views/PersonProfileView.vue Client split by cutoff + promote, not Didar

Task 1: Schema + shared DTO fields

Files:

  • Modify: apps/api/prisma/schema.prisma
  • Create: migration under apps/api/prisma/migrations/
  • Modify: packages/shared/src/index.ts

Interfaces:

  • Produces:

    • Deal.promotedFromPriorAt DateTime?
    • Activity.promotedFromPriorAt DateTime?
    • Organization.priorArchiveEnabledAt comment = cutoff date (start of selected day)
    • AuthUserDto.priorArchive?: { lens: 'current' \| 'archive'; cutoffAt: string | null } | null
    • OrganizationDto.priorArchiveCutoffAt?: string | null
    • DealDto.promotedFromPriorAt: string | null
    • ActivityDto.promotedFromPriorAt?: string | null
    • UpdatePriorArchiveCutoffRequest { cutoffAt: string | null } (ISO date or null)
  • Step 1: Update Prisma models

On Organization, change comment of priorArchiveEnabledAt to: /// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر

On Deal and Activity, after importSource (or near createdAt), add:

  /// اگر پر باشد، رکورد از بایگانی به کار جاری منتقل شده (فقط برچسب؛ بقیه فیلدها دست‌نخورده)
  promotedFromPriorAt DateTime?

Add indexes optional: @@index([organizationId, createdAt]) only if missing; do not over-index.

  • Step 2: Create migration
cd apps/api; npx prisma migrate dev --name prior_archive_promote_cutoff

Expected: migration applies; client generates.

  • Step 3: Shared types in packages/shared/src/index.ts
// AuthUserDto
priorArchive?: { lens: "current" | "archive"; cutoffAt: string | null } | null;

// OrganizationDto
priorArchiveCutoffAt?: string | null;

export interface UpdatePriorArchiveCutoffRequest {
  /** ISO datetime (start of selected Jalali day) or null to clear */
  cutoffAt: string | null;
}

// DealDto
promotedFromPriorAt: string | null;

// ActivityDto
promotedFromPriorAt?: string | null;
  • Step 4: Build shared
npm run build -w @mento/shared

Expected: success.


Task 2: Rewrite prior-archive.ts (TDD)

Files:

  • Modify: apps/api/test/prior-archive.test.ts (replace entirely)
  • Modify: apps/api/src/common/prior-archive.ts (replace filter logic)

Interfaces:

  • Consumes: cutoff Date | null | undefined, lens

  • Produces:

    • dealPriorWhere(lens, cutoff): Prisma.DealWhereInput
    • activityPriorWhere(lens, cutoff): Prisma.ActivityWhereInput
    • Keep actorPriorLens, PriorLens, listPriorLens if still used; personPriorWhere / companyPriorWhere become always {} (or delete and fix imports in Task 3)
  • Step 1: Write failing tests — replace apps/api/test/prior-archive.test.ts with:

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  activityPriorWhere,
  dealPriorWhere,
  personPriorWhere,
} from '../src/common/prior-archive';

const cutoff = new Date('2026-10-01T00:00:00.000Z');

describe('بایگانی اطلاعات قبل — تاریخ قطع', () => {
  it('بدون لنز یا بدون تاریخ فیلتر نمی‌گذارد', () => {
    assert.deepEqual(dealPriorWhere('off', cutoff), {});
    assert.deepEqual(dealPriorWhere('current', null), {});
    assert.deepEqual(activityPriorWhere('archive', undefined), {});
    assert.deepEqual(personPriorWhere('current'), {});
  });

  it('کار جاری: بعد از قطع یا منتقل‌شده', () => {
    assert.deepEqual(dealPriorWhere('current', cutoff), {
      OR: [
        { createdAt: { gte: cutoff } },
        { promotedFromPriorAt: { not: null } },
      ],
    });
  });

  it('بایگانی: قبل از قطع و بدون برچسب انتقال', () => {
    assert.deepEqual(activityPriorWhere('archive', cutoff), {
      createdAt: { lt: cutoff },
      promotedFromPriorAt: null,
    });
  });

  it('استثنای موعد آینده دیگر نیست', () => {
    const w = JSON.stringify(dealPriorWhere('current', cutoff));
    assert.equal(w.includes('expectedCloseAt'), false);
    assert.equal(w.includes('didar'), false);
  });
});
  • Step 2: Run tests — expect FAIL
cd apps/api; npx tsx --test test/prior-archive.test.ts
  • Step 3: Implement apps/api/src/common/prior-archive.ts:
import type { Prisma } from '@prisma/client';
import type { Actor } from './record-scope';

export type PriorLens = 'off' | 'current' | 'archive';

export function actorPriorLens(actor: Pick<Actor, 'priorLens'> | undefined): PriorLens {
  return actor?.priorLens ?? 'off';
}

function hasCutoff(cutoff?: Date | null): cutoff is Date {
  return cutoff instanceof Date && !Number.isNaN(cutoff.getTime());
}

/** شخص/شرکت دیگر با این لنز فیلتر نمی‌شوند. */
export function personPriorWhere(_lens?: PriorLens): Prisma.PersonWhereInput {
  return {};
}

export function companyPriorWhere(_lens?: PriorLens): Prisma.CompanyWhereInput {
  return {};
}

export function dealPriorWhere(
  lens: PriorLens | undefined,
  cutoff?: Date | null,
): Prisma.DealWhereInput {
  if (lens !== 'current' && lens !== 'archive') return {};
  if (!hasCutoff(cutoff)) return {};
  if (lens === 'current') {
    return {
      OR: [
        { createdAt: { gte: cutoff } },
        { promotedFromPriorAt: { not: null } },
      ],
    };
  }
  return { createdAt: { lt: cutoff }, promotedFromPriorAt: null };
}

export function activityPriorWhere(
  lens: PriorLens | undefined,
  cutoff?: Date | null,
): Prisma.ActivityWhereInput {
  if (lens !== 'current' && lens !== 'archive') return {};
  if (!hasCutoff(cutoff)) return {};
  if (lens === 'current') {
    return {
      OR: [
        { createdAt: { gte: cutoff } },
        { promotedFromPriorAt: { not: null } },
      ],
    };
  }
  return { createdAt: { lt: cutoff }, promotedFromPriorAt: null };
}

/** فهرست شخص/شرکت: همیشه بدون فیلتر لنز (سازگاری با call-siteهای قدیمی). */
export function listPriorLens(
  _lens: PriorLens | undefined,
  _q: string | undefined,
  _explicitSource: boolean,
): PriorLens {
  return 'off';
}

Remove PRIOR_IMPORT_SOURCE, liveDeal, liveActivity, historicalActivity, stillOpenAhead.

  • Step 4: Run tests — expect PASS
cd apps/api; npx tsx --test test/prior-archive.test.ts

Task 3: Wire API call-sites + auth cutoff + stop auto-stamp

Files:

  • Modify: apps/api/src/auth/jwt.strategy.ts
  • Modify: apps/api/src/auth/auth.service.ts (priorArchiveFor)
  • Modify: apps/api/src/billing/billing.service.ts (do not set priorArchiveEnabledAt on module enable)
  • Modify call sites of dealPriorWhere / activityPriorWhere / person+company filters:
    • apps/api/src/pipelines/pipelines.service.ts
    • apps/api/src/pipelines/deals-excel.service.ts
    • apps/api/src/activities/activities.service.ts
    • apps/api/src/reports/reports.service.ts
    • apps/api/src/ai/ai-ask.service.ts
    • apps/api/src/contacts/people.service.ts
    • apps/api/src/contacts/companies.service.ts
  • Modify DTO mappers: pipelines.service.ts toDealDto, activities.service.ts toDto

Interfaces:

  • Consumes: actor.priorCutoff, new dealPriorWhere(lens, cutoff) signature (no now)

  • Produces: auth payload includes cutoffAt; DTOs include promotedFromPriorAt

  • Step 1: jwt.strategy — set cutoff only when granted:

priorLens: priorGranted ? priorLens : 'off',
priorCutoff:
  priorGranted && membership.organization.priorArchiveEnabledAt
    ? membership.organization.priorArchiveEnabledAt
    : null,
  • Step 2: auth.service priorArchiveFor — return:
return {
  lens: membership.priorArchiveLens === 'archive' ? 'archive' : 'current',
  cutoffAt: orgCutoff ? orgCutoff.toISOString() : null,
};

Load org cutoff in the membership select used by me / login (same field already on org via jwt; ensure me path also has it).

  • Step 3: Fix every dealPriorWhere(actor.priorLens, now) → dealPriorWhere(actor.priorLens, actor.priorCutoff) and activityPriorWhere(lens, now, cutoff) → activityPriorWhere(lens, cutoff).

  • Step 4: Remove person/company prior filtering from people/companies list and reports/ai-ask (either delete spreads of personPriorWhere / companyPriorWhere or leave no-ops from Task 2).

  • Step 5: Map promotedFromPriorAt in toDealDto / activity toDto:

promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
  • Step 6: billing.service — when enabling prior_data_archive, do not write priorArchiveEnabledAt. Leave existing values untouched. Owner sets cutoff via Task 4.

  • Step 7: Typecheck

npm run typecheck -w @mento/api

Expected: pass (fix any remaining old signatures).


Task 4: Owner API — set org cutoff date

Files:

  • Modify: apps/api/src/organizations/dto.ts
  • Modify: apps/api/src/organizations/organizations.service.ts (toDto, new method)
  • Modify: apps/api/src/organizations/organizations.controller.ts
  • Modify: packages/shared if UpdatePriorArchiveCutoffRequest not already added in Task 1

Interfaces:

  • Produces: PATCH /organizations/current/prior-archive-cutoff body { cutoffAt: string | null }

  • Roles: @Roles('owner') only (same as user prior-archive toggle)

  • getCurrent includes priorArchiveCutoffAt

  • Step 1: Service method

async updatePriorArchiveCutoff(
  organizationId: string,
  cutoffAt: string | null,
): Promise<OrganizationDto> {
  const modules = /* load enabledModules */;
  if (!modules.includes('prior_data_archive')) {
    throw new BadRequestException('ماژول بایگانی اطلاعات قبل برای این محیط فعال نیست.');
  }
  let value: Date | null = null;
  if (cutoffAt != null && cutoffAt !== '') {
    const d = new Date(cutoffAt);
    if (Number.isNaN(d.getTime())) {
      throw new BadRequestException('تاریخ قطع معتبر نیست.');
    }
    value = d;
  }
  await this.prisma.organization.update({
    where: { id: organizationId },
    data: { priorArchiveEnabledAt: value },
  });
  return this.getCurrent(organizationId);
}

Include priorArchiveCutoffAt: org.priorArchiveEnabledAt?.toISOString() ?? null in toDto.

  • Step 2: Controller
@Patch('current/prior-archive-cutoff')
@Roles('owner')
updatePriorArchiveCutoff(
  @CurrentUser() user: { organizationId: string },
  @Body() body: { cutoffAt?: string | null },
) {
  return this.organizationsService.updatePriorArchiveCutoff(
    user.organizationId,
    body.cutoffAt === undefined ? null : body.cutoffAt,
  );
}

Treat missing vs null carefully: prefer explicit { cutoffAt: null } to clear; require property present.

  • Step 3: Manual smoke — owner PATCH with ISO start-of-day; GET current shows field.

Task 5: Promote endpoints (deal + activity)

Files:

  • Modify: apps/api/src/pipelines/deals.service.ts
  • Modify: apps/api/src/pipelines/deals.controller.ts
  • Modify: apps/api/src/activities/activities.service.ts
  • Modify: apps/api/src/activities/activities.controller.ts

Interfaces:

  • Produces:

    • POST /deals/:id/promote-from-prior → DealDto
    • POST /activities/:id/promote-from-prior → ActivityDto
  • Rules: actor priorLens is current or archive (grant on); cutoff set; record createdAt < cutoff; promotedFromPriorAt is null; access where passes; set only promotedFromPriorAt: new Date().

  • Step 1: deals.service.promoteFromPrior

async promoteFromPrior(actor: Actor, id: string): Promise<DealDto> {
  if (actor.priorLens === 'off' || !actor.priorCutoff) {
    throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست.');
  }
  const row = await this.prisma.deal.findFirst({
    where: { id, organizationId: actor.organizationId, ...dealAccessWhere(actor) },
  });
  if (!row) throw new NotFoundException('معامله پیدا نشد');
  if (row.promotedFromPriorAt) {
    throw new BadRequestException('این معامله قبلاً منتقل شده است.');
  }
  if (row.createdAt >= actor.priorCutoff) {
    throw new BadRequestException('این معامله در بایگانی نیست.');
  }
  const updated = await this.prisma.deal.update({
    where: { id },
    data: { promotedFromPriorAt: new Date() },
    include: /* same as get */,
  });
  return this.pipelines.toDealDto(updated);
}

Mirror for activities with activityAccessWhere.

  • Step 2: Controllers — POST :id/promote-from-prior before generic :id routes if needed (Nest order: static segments already fine as long as path is under :id/...).

  • Step 3: Verify with a prior deal: after promote, appears in current lens lists; createdAt unchanged.


Task 6: Teams settings UI — cutoff date + user multi-select

Files:

  • Modify: apps/web/src/components/TeamsSettingsPanel.vue
  • Possibly refresh auth after cutoff change so priorArchive.cutoffAt updates for the owner session

Interfaces:

  • Consumes: GET /organizations/current → priorArchiveCutoffAt; PATCH .../prior-archive-cutoff; existing PATCH /users/:id/prior-archive

  • Produces: compact card UI

  • Step 1: Header row — next to <h3>بایگانی اطلاعات قبل</h3>:

<header class="prior-head">
  <h3>بایگانی اطلاعات قبل</h3>
  <JalaliDateInput
    :model-value="cutoffLocal"
    mode="date"
    :disabled="cutoffSaving"
    @update:model-value="onCutoffChange"
  />
</header>

Load cutoff on mount from /organizations/current. On change, convert selected Jalali day to ISO start of that day (reuse helpers already used with JalaliDateInput elsewhere — same as deal expected-close date storage). PATCH cutoff. If cleared, send null.

  • Step 2: Replace checkbox list with searchable multi-select:

  • Show a <select> or combobox of active users not yet enabled; on pick, call setPriorArchive(id, true).

  • Show selected users as removable chips/rows; remove → setPriorArchive(id, false).

  • Keep existing API; only UX changes.

  • Hint text stays: only selected users see the switch.

  • Step 3: Style — keep card compact; date aligned with title (flex, gap); chips wrap.

  • Step 4: Manual check — with many users, card height stays small; date saves; selected users see sidebar switch after refresh/auth reload.


Task 7: Three-dot promote UI + PersonProfile client rules

Files:

  • Modify: apps/web/src/views/ActivitiesView.vue (both desktop + mobile UiDropdownMenu blocks)
  • Modify: apps/web/src/views/PipelineBoardView.vue (menu-pop)
  • Modify: apps/web/src/views/PersonProfileView.vue (isPriorDeal / isPriorActivity)

Interfaces:

  • Show «انتقال به کار جاری» only when:

    • auth.user?.priorArchive?.lens === 'archive'
    • item has no promotedFromPriorAt
    • createdAt < cutoffAt (if cutoff missing, hide)
  • POST promote endpoint; refresh list / close menu; toast/ok optional

  • Step 1: ActivitiesView — inside each UiDropdownMenu, after ویرایش:

<button
  v-if="canPromotePrior(item)"
  type="button"
  @click="promotePriorActivity(item.id)"
>
  انتقال به کار جاری
</button>
function canPromotePrior(item: ActivityDto) {
  const pa = auth.user?.priorArchive;
  if (!pa || pa.lens !== 'archive' || !pa.cutoffAt) return false;
  if (item.promotedFromPriorAt) return false;
  return new Date(item.createdAt).getTime() < new Date(pa.cutoffAt).getTime();
}

async function promotePriorActivity(id: string) {
  menuId.value = null;
  await api.post(`/activities/${id}/promote-from-prior`);
  // reload list the same way delete/complete does
}
  • Step 2: PipelineBoardView — same gate using deal from board data; api.post(/deals/${id}/promote-from-prior); reload board.

  • Step 3: PersonProfileView — replace Didar-based helpers:

function isPriorDeal(row: DealDto) {
  const cutoff = auth.user?.priorArchive?.cutoffAt;
  if (!auth.user?.priorArchive || !cutoff) return false;
  if (row.promotedFromPriorAt) return false;
  return new Date(row.createdAt).getTime() < new Date(cutoff).getTime();
}
// same for activities

When prior archive is off for user, show all deals/activities in the normal sections (existing priorArchiveOn false path).

  • Step 4: Manual QA checklist
    1. User without grant: full deals/activities, no switch
    2. Grant + no cutoff: full lists
    3. Set cutoff: current empty of pre-cutoff deals/activities (even open future due)
    4. Archive lens: only pre-cutoff non-promoted
    5. Promote from menu: appears in current; dates unchanged; no reverse action
    6. People/leads unchanged across lenses

Task 8: Final verification + graphify

  • Step 1: Tests
cd apps/api; npm test

Expected: all green including prior-archive.test.ts.

  • Step 2: Builds
npm run build -w @mento/shared; npm run build -w @mento/api; npm run build -w @mento/web
  • Step 3: graphify
graphify update .
  • Step 4: Mark old design pointer already done in 2026-10-14-...; ensure new spec status stays accurate.

Spec coverage (self-review)

Spec requirement Task
Org-wide Jalali cutoff 1, 4, 6
Filter only deals/activities by createdAt 2, 3
Promote tag only, no field mutation 5
One-way, any granted viewer 5, 7
No filter without grant 3, jwt
No filter without cutoff 2, 3
Default lens current when granted existing auth; unchanged
Teams UI date + multi-select 6
Three-dot promote in archive lens 7
Drop Didar + future-due exception 2, 7
Person/lead/rules unfiltered 2, 3

No TBD placeholders. Signatures: dealPriorWhere(lens, cutoff) and activityPriorWhere(lens, cutoff) consistently; promote field name promotedFromPriorAt everywhere.