docs: add prior-archive cutoff plan and ignore worktrees

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 16:07:59 +03:30
co-authored by Cursor
parent 03e1c01023
commit 27635c0752
2 changed files with 590 additions and 0 deletions
+4
View File
@@ -32,3 +32,7 @@ playwright-report/
graphify-out/
**/graphify-out/
.cursor/rules/graphify.mdc
# Local git worktrees + SDD scratch
.worktrees/
.superpowers/
@@ -0,0 +1,586 @@
# 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<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**
```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<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**
```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<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>`:
```vue
<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 ویرایش:
```vue
<button
v-if="canPromotePrior(item)"
type="button"
@click="promotePriorActivity(item.id)"
>
انتقال به کار جاری
</button>
```
```ts
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:
```ts
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**
```powershell
cd apps/api; npm test
```
Expected: all green including `prior-archive.test.ts`.
- [ ] **Step 2: Builds**
```powershell
npm run build -w @mento/shared; npm run build -w @mento/api; npm run build -w @mento/web
```
- [ ] **Step 3: graphify**
```powershell
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.