From 27635c0752013af95fabaced628243d4c110f575 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 17 Oct 2026 16:07:59 +0330 Subject: [PATCH] docs: add prior-archive cutoff plan and ignore worktrees Co-authored-by: Cursor --- .gitignore | 4 + .../2026-10-17-prior-data-archive-cutoff.md | 586 ++++++++++++++++++ 2 files changed, 590 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-17-prior-data-archive-cutoff.md diff --git a/.gitignore b/.gitignore index 18385b7..11d01ec 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ playwright-report/ graphify-out/ **/graphify-out/ .cursor/rules/graphify.mdc + +# Local git worktrees + SDD scratch +.worktrees/ +.superpowers/ diff --git a/docs/superpowers/plans/2026-10-17-prior-data-archive-cutoff.md b/docs/superpowers/plans/2026-10-17-prior-data-archive-cutoff.md new file mode 100644 index 0000000..2b2d1a8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-prior-data-archive-cutoff.md @@ -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 | 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 `