# 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: ```prisma /// اگر پر باشد، رکورد از بایگانی به کار جاری منتقل شده (فقط برچسب؛ بقیه فیلدها دست‌نخورده) promotedFromPriorAt DateTime? ``` Add indexes optional: `@@index([organizationId, createdAt])` only if missing; do not over-index. - [ ] **Step 2: Create migration** ```powershell 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` ```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** ```powershell 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: ```ts 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** ```powershell cd apps/api; npx tsx --test test/prior-archive.test.ts ``` - [ ] **Step 3: Implement** `apps/api/src/common/prior-archive.ts`: ```ts import type { Prisma } from '@prisma/client'; import type { Actor } from './record-scope'; export type PriorLens = 'off' | 'current' | 'archive'; export function actorPriorLens(actor: Pick | 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** ```powershell 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: ```ts priorLens: priorGranted ? priorLens : 'off', priorCutoff: priorGranted && membership.organization.priorArchiveEnabledAt ? membership.organization.priorArchiveEnabledAt : null, ``` - [ ] **Step 2: auth.service `priorArchiveFor`** — return: ```ts 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`: ```ts 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** ```powershell 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** ```ts async updatePriorArchiveCutoff( organizationId: string, cutoffAt: string | null, ): Promise { 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** ```ts @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** ```ts async promoteFromPrior(actor: Actor, id: string): Promise { 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 `

بایگانی اطلاعات قبل

`: ```vue

بایگانی اطلاعات قبل

``` 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 `