Skip to content

Commit 54fa9d3

Browse files
FentPamsclaude
andcommitted
fix(plg): preserve V2 re-detected OUTDATED suggestion grants
V2 re-detected OUTDATED rows (previousDeployment stamp present in recommendations[0]) represent actionable work — PLG customers must be able to re-deploy them. The grant revocation predicate previously treated all OUTDATED as stale, revoking the token grant and causing a 403 on re-deploy. Only revoke OUTDATED grants that lack the previousDeployment stamp (V1 legacy windowing rows), which are genuinely stale book-keeping. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4d8e1a8 commit 54fa9d3

2 files changed

Lines changed: 123 additions & 5 deletions

File tree

src/support/grant-suggestions-handler.js

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,19 @@ const STALE_STATUSES = new Set([
217217
SuggestionModel.STATUSES.PENDING_VALIDATION,
218218
]);
219219

220-
const isRevocable = (status) => STALE_STATUSES.has(status)
221-
|| status === SuggestionModel.STATUSES.NEW;
220+
// V2 re-detected OUTDATED rows have a previousDeployment stamp and represent
221+
// actionable work — their grants must stay live so PLG customers can re-deploy.
222+
// V1 legacy OUTDATED rows (no stamp) are stale book-keeping and should be revoked.
223+
const isRevocable = (suggestion) => {
224+
const status = suggestion.getStatus();
225+
if (status === SuggestionModel.STATUSES.OUTDATED) {
226+
const recs = suggestion.getData?.()?.recommendations;
227+
const isV2Redetected = Array.isArray(recs) && recs.length > 0
228+
&& Boolean(recs[0]?.previousDeployment);
229+
return !isV2Redetected;
230+
}
231+
return STALE_STATUSES.has(status) || status === SuggestionModel.STATUSES.NEW;
232+
};
222233

223234
/**
224235
* Handles a new token cycle: creates the token and migrates
@@ -290,7 +301,7 @@ async function handleExistingTokenCycle(
290301
tokenGrants
291302
.filter((g) => {
292303
const s = grantedSuggestions.find((gs) => gs?.getId() === g.getSuggestionId());
293-
return s && isRevocable(s.getStatus());
304+
return s && isRevocable(s);
294305
})
295306
.map((g) => g.getGrantId()),
296307
)];
@@ -328,9 +339,10 @@ async function fillRemainingCapacity(
328339
*
329340
* **When a token already exists (current cycle):**
330341
* Fetches all grants for the current token. If any granted
331-
* suggestion is in a revocable state (OUTDATED, REJECTED,
342+
* suggestion is in a revocable state (V1 OUTDATED, REJECTED,
332343
* PENDING_VALIDATION, NEW), revokes only those grants, leaving
333-
* permanent states (e.g. APPROVED) untouched. Fills remaining
344+
* permanent states (e.g. APPROVED) and V2 re-detected OUTDATED
345+
* (with previousDeployment stamp) untouched. Fills remaining
334346
* capacity from NEW ungranted suggestions.
335347
*
336348
* **When no token exists (new cycle):**

test/support/grant-suggestions-handler.test.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,112 @@ describe('grant-suggestions-handler', () => {
721721
expect(SuggestionGrant.grantSuggestions).to.have.been.calledOnce;
722722
});
723723

724+
it('does not revoke V2 re-detected OUTDATED grant (has previousDeployment stamp)', async () => {
725+
const s1 = {
726+
getId: () => 'sugg-1', getRank: () => 1, getStatus: () => 'NEW',
727+
};
728+
729+
const existingToken = { getId: () => 'tok-1', getRemaining: () => 1 };
730+
731+
// V2 re-detected OUTDATED: has previousDeployment stamp in data
732+
const sugg2V2Outdated = {
733+
getId: () => 'sugg-2',
734+
getRank: () => 2,
735+
getStatus: () => 'OUTDATED',
736+
getData: () => ({
737+
recommendations: [{ previousDeployment: { altText: 'old text', deployedAt: '2026-05-01' } }],
738+
}),
739+
};
740+
741+
const Suggestion = {
742+
allByOpportunityIdAndStatus: sandbox.stub().resolves([s1]),
743+
batchGetByKeys: sandbox.stub().resolves({
744+
data: [sugg2V2Outdated],
745+
unprocessed: [],
746+
}),
747+
};
748+
749+
const SuggestionGrant = {
750+
splitSuggestionsByGrantStatus: sandbox.stub().resolves({
751+
grantedIds: [],
752+
grantIds: [],
753+
notGrantedIds: ['sugg-1'],
754+
}),
755+
allByIndexKeys: sandbox.stub().resolves([
756+
mkGrant('sugg-2', 'g2'),
757+
]),
758+
grantSuggestions: sandbox.stub().resolves({ success: true }),
759+
revokeSuggestionGrant: sandbox.stub(),
760+
};
761+
762+
const Token = {
763+
findBySiteIdAndTokenType: sandbox.stub().resolves(existingToken),
764+
};
765+
766+
const dataAccess = { Suggestion, SuggestionGrant, Token };
767+
768+
await grantSuggestionsForOpportunity(dataAccess, site, opportunity);
769+
770+
// V2 OUTDATED grant must NOT be revoked — it's actionable work for PLG re-deploy
771+
expect(SuggestionGrant.revokeSuggestionGrant).to.not.have.been.called;
772+
// Token had remaining=1, fill with the ungranted NEW suggestion
773+
expect(SuggestionGrant.grantSuggestions).to.have.been.calledOnce;
774+
});
775+
776+
it('revokes V1 legacy OUTDATED grant (no previousDeployment stamp)', async () => {
777+
const s1 = {
778+
getId: () => 'sugg-1', getRank: () => 1, getStatus: () => 'NEW',
779+
};
780+
781+
const existingToken = { getId: () => 'tok-1', getRemaining: () => 0 };
782+
const tokenAfterRevoke = { getId: () => 'tok-1', getRemaining: () => 1 };
783+
784+
// V1 legacy OUTDATED: getData returns no previousDeployment stamp
785+
const sugg2V1Outdated = {
786+
getId: () => 'sugg-2',
787+
getRank: () => 2,
788+
getStatus: () => 'OUTDATED',
789+
getData: () => ({ recommendations: [{ altText: 'some text' }] }),
790+
};
791+
792+
const Suggestion = {
793+
allByOpportunityIdAndStatus: sandbox.stub().resolves([s1]),
794+
batchGetByKeys: sandbox.stub().resolves({
795+
data: [sugg2V1Outdated],
796+
unprocessed: [],
797+
}),
798+
};
799+
800+
const SuggestionGrant = {
801+
splitSuggestionsByGrantStatus: sandbox.stub().resolves({
802+
grantedIds: [],
803+
grantIds: [],
804+
notGrantedIds: ['sugg-1'],
805+
}),
806+
allByIndexKeys: sandbox.stub().resolves([
807+
mkGrant('sugg-2', 'g2'),
808+
]),
809+
grantSuggestions: sandbox.stub().resolves({ success: true }),
810+
revokeSuggestionGrant: sandbox.stub().resolves({ success: true }),
811+
};
812+
813+
const Token = {
814+
findBySiteIdAndTokenType: sandbox.stub(),
815+
};
816+
Token.findBySiteIdAndTokenType
817+
.onFirstCall().resolves(existingToken)
818+
.onSecondCall().resolves(tokenAfterRevoke);
819+
820+
const dataAccess = { Suggestion, SuggestionGrant, Token };
821+
822+
await grantSuggestionsForOpportunity(dataAccess, site, opportunity);
823+
824+
// V1 OUTDATED grant must be revoked (stale, no re-detection stamp)
825+
expect(SuggestionGrant.revokeSuggestionGrant).to.have.been.calledOnce;
826+
expect(SuggestionGrant.revokeSuggestionGrant).to.have.been.calledWith('g2');
827+
expect(SuggestionGrant.grantSuggestions).to.have.been.calledOnce;
828+
});
829+
724830
it('revokes grants with PENDING_VALIDATION status as stale', async () => {
725831
const s1 = {
726832
getId: () => 'sugg-1', getRank: () => 1, getStatus: () => 'NEW',

0 commit comments

Comments
 (0)