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(needsapps/api/.env) - Shell is PowerShell: use
;not&& - Transfer never changes
createdAtor any field exceptpromotedFromPriorAt - 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.priorArchiveEnabledAtcomment = cutoff date (start of selected day)AuthUserDto.priorArchive?: { lens: 'current' \| 'archive'; cutoffAt: string | null } | nullOrganizationDto.priorArchiveCutoffAt?: string | nullDealDto.promotedFromPriorAt: string | nullActivityDto.promotedFromPriorAt?: string | nullUpdatePriorArchiveCutoffRequest { 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.DealWhereInputactivityPriorWhere(lens, cutoff): Prisma.ActivityWhereInput- Keep
actorPriorLens,PriorLens,listPriorLensif still used;personPriorWhere/companyPriorWherebecome always{}(or delete and fix imports in Task 3)
-
Step 1: Write failing tests — replace
apps/api/test/prior-archive.test.tswith:
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 setpriorArchiveEnabledAton module enable) - Modify call sites of
dealPriorWhere/activityPriorWhere/ person+company filters:apps/api/src/pipelines/pipelines.service.tsapps/api/src/pipelines/deals-excel.service.tsapps/api/src/activities/activities.service.tsapps/api/src/reports/reports.service.tsapps/api/src/ai/ai-ask.service.tsapps/api/src/contacts/people.service.tsapps/api/src/contacts/companies.service.ts
- Modify DTO mappers:
pipelines.service.tstoDealDto,activities.service.tstoDto
Interfaces:
-
Consumes:
actor.priorCutoff, newdealPriorWhere(lens, cutoff)signature (nonow) -
Produces: auth payload includes
cutoffAt; DTOs includepromotedFromPriorAt -
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)andactivityPriorWhere(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/companyPriorWhereor leave no-ops from Task 2). -
Step 5: Map
promotedFromPriorAtintoDealDto/ activitytoDto:
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
-
Step 6: billing.service — when enabling
prior_data_archive, do not writepriorArchiveEnabledAt. 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/sharedifUpdatePriorArchiveCutoffRequestnot already added in Task 1
Interfaces:
-
Produces:
PATCH /organizations/current/prior-archive-cutoffbody{ cutoffAt: string | null } -
Roles:
@Roles('owner')only (same as user prior-archive toggle) -
getCurrentincludespriorArchiveCutoffAt -
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→DealDtoPOST /activities/:id/promote-from-prior→ActivityDto
-
Rules: actor
priorLensiscurrentorarchive(grant on); cutoff set; recordcreatedAt < cutoff;promotedFromPriorAtis null; access where passes; set onlypromotedFromPriorAt: 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-priorbefore generic:idroutes 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;
createdAtunchanged.
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.cutoffAtupdates for the owner session
Interfaces:
-
Consumes:
GET /organizations/current→priorArchiveCutoffAt;PATCH .../prior-archive-cutoff; existingPATCH /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, callsetPriorArchive(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/
authreload.
Task 7: Three-dot promote UI + PersonProfile client rules
Files:
- Modify:
apps/web/src/views/ActivitiesView.vue(both desktop + mobileUiDropdownMenublocks) - 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
- User without grant: full deals/activities, no switch
- Grant + no cutoff: full lists
- Set cutoff: current empty of pre-cutoff deals/activities (even open future due)
- Archive lens: only pre-cutoff non-promoted
- Promote from menu: appears in current; dates unchanged; no reverse action
- 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.