From 07f090d4ec7b60ddab3d5eea49e14a445ea75781 Mon Sep 17 00:00:00 2001 From: The-Big-Danny Date: Mon, 28 Sep 2026 00:44:07 +0100 Subject: [PATCH] feat: index key deprecation events and expose deprecation status endpoint Add GET /keys/:keyId/deprecation returning status, reason, deprecatedAt, and an embedded successor key summary. Add a KEY_DEPRECATED contract event indexer that idempotently applies deprecation to CreatorProfile and notifies current holders (dedup'd per event via Redis). Flag deprecated keys in the creator list and key search endpoints, and surface reason/successor in the existing notification feed. Co-Authored-By: Claude Sonnet 5 --- prisma/schema/creator.prisma | 7 + .../migration.sql | 9 + .../creator-list-projection.constants.ts | 2 + src/constants/notifications.constants.ts | 1 + .../creator-list-field-picker.utils.test.ts | 2 + .../creator-list-field-picker.utils.ts | 1 + .../creators/creator-list-item.mapper.test.ts | 11 + .../creators/creator-list-item.mapper.ts | 3 + .../key-deprecation-indexer.service.test.ts | 117 ++++++++++ .../key-deprecation-indexer.service.ts | 106 +++++++++ .../keys/key-deprecation.service.test.ts | 85 ++++++- src/modules/keys/key-deprecation.service.ts | 217 ++++++++++++++++++ src/modules/keys/key-search.service.test.ts | 19 ++ src/modules/keys/key-search.service.ts | 4 + src/modules/keys/keys.routes.ts | 35 +++ .../notifications/notification.service.ts | 4 + src/types/profile.types.ts | 2 + 17 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 prisma/schema/migrations/20260928000000_add_key_deprecation_reason_successor/migration.sql create mode 100644 src/modules/indexer/key-deprecation-indexer.service.test.ts create mode 100644 src/modules/indexer/key-deprecation-indexer.service.ts diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index e5237ff8..7cdfc229 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -31,6 +31,10 @@ model CreatorProfile { buybackPriceXlm Decimal? @db.Decimal(20, 7) /// Buyback window expiry; buybacks are rejected with 410 after this time (#882). buybackExpiresAt DateTime? + /// Reason for deprecation, set by an admin action or an indexed contract event. + reason String? + /// Successor key designated to replace this one on deprecation, if any. + successorKeyId String? /// Buy cooldown between purchases by the same wallet, in ledgers (0 = no cooldown). cooldownLedgers Int @default(0) perks Json? @@ -47,8 +51,11 @@ model CreatorProfile { followers Follow[] walletFollowers WalletCreatorFollow[] relaunches CreatorKeyRelaunch[] + successor CreatorProfile? @relation("KeySuccessor", fields: [successorKeyId], references: [id], onDelete: SetNull) + supersededKeys CreatorProfile[] @relation("KeySuccessor") @@index([userId]) + @@index([successorKeyId]) } model CreatorKeyRelaunch { diff --git a/prisma/schema/migrations/20260928000000_add_key_deprecation_reason_successor/migration.sql b/prisma/schema/migrations/20260928000000_add_key_deprecation_reason_successor/migration.sql new file mode 100644 index 00000000..e74e0ba8 --- /dev/null +++ b/prisma/schema/migrations/20260928000000_add_key_deprecation_reason_successor/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "CreatorProfile" +ADD COLUMN "reason" TEXT, +ADD COLUMN "successorKeyId" TEXT; + +CREATE INDEX "CreatorProfile_successorKeyId_idx" ON "CreatorProfile"("successorKeyId"); + +ALTER TABLE "CreatorProfile" +ADD CONSTRAINT "CreatorProfile_successorKeyId_fkey" +FOREIGN KEY ("successorKeyId") REFERENCES "CreatorProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/constants/creator-list-projection.constants.ts b/src/constants/creator-list-projection.constants.ts index a4e65ddb..25778eb6 100644 --- a/src/constants/creator-list-projection.constants.ts +++ b/src/constants/creator-list-projection.constants.ts @@ -19,6 +19,7 @@ * - isVerified: Verification status badge * - createdAt: Creator registration timestamp * - updatedAt: Creator profile update timestamp + * - deprecatedAt: Deprecation timestamp (null while the key is active) * - priceSnapshot: Nested price read model (currentPrice, price24hAgo, lastTradeAt) */ export const CREATOR_LIST_DEFAULT_SELECT = { @@ -29,6 +30,7 @@ export const CREATOR_LIST_DEFAULT_SELECT = { isVerified: true, createdAt: true, updatedAt: true, + deprecatedAt: true, priceSnapshot: { select: { currentPrice: true, diff --git a/src/constants/notifications.constants.ts b/src/constants/notifications.constants.ts index 2653ffe9..f133afd5 100644 --- a/src/constants/notifications.constants.ts +++ b/src/constants/notifications.constants.ts @@ -26,6 +26,7 @@ export const REDIS_KEYS = { priceMovedSet: 'price_moved:keys', priceMovedDelivered: (keyId: string) => `price_moved:delivered:${keyId}`, keySunsetEvent: (eventId: string) => `key_sunset:dispatch:${eventId}`, + keyDeprecationEvent: (eventId: string) => `key_deprecation:dispatch:${eventId}`, } as const; export const KEY_FEES_CACHE_TTL_SECONDS = 60; diff --git a/src/modules/creators/creator-list-field-picker.utils.test.ts b/src/modules/creators/creator-list-field-picker.utils.test.ts index a0326996..6904b076 100644 --- a/src/modules/creators/creator-list-field-picker.utils.test.ts +++ b/src/modules/creators/creator-list-field-picker.utils.test.ts @@ -16,6 +16,7 @@ const sampleItem: CreatorListItem = { currentPrice: '1000', price24hAgo: '900', priceChange24h: 11.11, + deprecated: false, }; describe('CREATOR_LIST_PUBLIC_FIELDS', () => { @@ -30,6 +31,7 @@ describe('CREATOR_LIST_PUBLIC_FIELDS', () => { 'currentPrice', 'price24hAgo', 'priceChange24h', + 'deprecated', ]; for (const field of expected) { expect(CREATOR_LIST_PUBLIC_FIELDS).toContain(field); diff --git a/src/modules/creators/creator-list-field-picker.utils.ts b/src/modules/creators/creator-list-field-picker.utils.ts index c74f2403..da485065 100644 --- a/src/modules/creators/creator-list-field-picker.utils.ts +++ b/src/modules/creators/creator-list-field-picker.utils.ts @@ -10,6 +10,7 @@ export const CREATOR_LIST_PUBLIC_FIELDS = [ 'currentPrice', 'price24hAgo', 'priceChange24h', + 'deprecated', ] as const; export type CreatorListPublicField = diff --git a/src/modules/creators/creator-list-item.mapper.test.ts b/src/modules/creators/creator-list-item.mapper.test.ts index c5f3ad3b..80d4c29f 100644 --- a/src/modules/creators/creator-list-item.mapper.test.ts +++ b/src/modules/creators/creator-list-item.mapper.test.ts @@ -25,6 +25,17 @@ describe('mapCreatorListItem()', () => { currentPrice: null, price24hAgo: null, priceChange24h: null, + deprecated: false, }); }); + + it('flags a deprecated key', async () => { + const input = createSeededCreatorFixture(2, { + deprecatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + const result = await mapCreatorListItem(input); + + expect(result.deprecated).toBe(true); + }); }); diff --git a/src/modules/creators/creator-list-item.mapper.ts b/src/modules/creators/creator-list-item.mapper.ts index 3dcdb0a2..14ec120e 100644 --- a/src/modules/creators/creator-list-item.mapper.ts +++ b/src/modules/creators/creator-list-item.mapper.ts @@ -24,6 +24,8 @@ export type CreatorListItem = { price24hAgo: string | null; /** Computed percentage change: ((current - 24h) / 24h) * 100. null when no baseline. */ priceChange24h: number | null; + /** True when the key has been deprecated (see GET /keys/:keyId/deprecation). */ + deprecated: boolean; }; type ExpectedFieldType = 'string' | 'boolean' | 'number' | 'Date'; @@ -134,5 +136,6 @@ export const mapCreatorListItem = async ( currentPrice: currentPrice !== null ? currentPrice.toString() : null, price24hAgo: price24hAgo !== null ? price24hAgo.toString() : null, priceChange24h, + deprecated: safeRead(creator, 'deprecatedAt', null) !== null, }; }; diff --git a/src/modules/indexer/key-deprecation-indexer.service.test.ts b/src/modules/indexer/key-deprecation-indexer.service.test.ts new file mode 100644 index 00000000..74115c83 --- /dev/null +++ b/src/modules/indexer/key-deprecation-indexer.service.test.ts @@ -0,0 +1,117 @@ +// src/modules/indexer/key-deprecation-indexer.service.test.ts +const prismaMock = { + creatorProfile: { + findUnique: jest.fn(), + update: jest.fn(), + }, + keyOwnership: { + findMany: jest.fn(), + }, + activityLog: { + create: jest.fn(), + }, +}; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: prismaMock, +})); + +jest.mock('../../utils/redis.utils', () => ({ + getRedis: () => null, +})); + +jest.mock('../creator/creator-dashboard.service', () => ({ + invalidateCreatorDashboardCache: jest.fn().mockResolvedValue(undefined), +})); + +import { processKeyDeprecationEvents } from './key-deprecation-indexer.service'; +import { invalidateCreatorDashboardCache } from '../creator/creator-dashboard.service'; +import { KeyDeprecationChainEvent } from './key-deprecation-indexer.service'; + +describe('processKeyDeprecationEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.keyOwnership.findMany.mockResolvedValue([]); + }); + + function makeEvent( + overrides: Partial = {} + ): KeyDeprecationChainEvent { + return { + eventType: 'KEY_DEPRECATED', + txHash: 'tx-1', + eventIndex: 0, + ledger: 100, + creatorId: 'creator-1', + ...overrides, + }; + } + + it('marks an active key deprecated, stores reason/successor, and invalidates cache', async () => { + prismaMock.creatorProfile.findUnique + .mockResolvedValueOnce({ id: 'creator-1', deprecatedAt: null }) // creator lookup + .mockResolvedValueOnce({ id: 'creator-2' }); // successor lookup + + await processKeyDeprecationEvents([ + makeEvent({ reason: 'sunset', successorKeyId: 'creator-2' }), + ]); + + expect(prismaMock.creatorProfile.update).toHaveBeenCalledWith({ + where: { id: 'creator-1' }, + data: expect.objectContaining({ + reason: 'sunset', + successorKeyId: 'creator-2', + }), + }); + expect(invalidateCreatorDashboardCache).toHaveBeenCalledWith( + 'creator-1' + ); + }); + + it('stores no successor when the referenced successor key does not exist', async () => { + prismaMock.creatorProfile.findUnique + .mockResolvedValueOnce({ id: 'creator-1', deprecatedAt: null }) + .mockResolvedValueOnce(null); + + await processKeyDeprecationEvents([ + makeEvent({ successorKeyId: 'missing-key' }), + ]); + + expect(prismaMock.creatorProfile.update).toHaveBeenCalledWith({ + where: { id: 'creator-1' }, + data: expect.objectContaining({ successorKeyId: null }), + }); + }); + + it('is idempotent: replaying an already-deprecated key skips the write', async () => { + prismaMock.creatorProfile.findUnique.mockResolvedValueOnce({ + id: 'creator-1', + deprecatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + await processKeyDeprecationEvents([makeEvent()]); + + expect(prismaMock.creatorProfile.update).not.toHaveBeenCalled(); + expect(invalidateCreatorDashboardCache).not.toHaveBeenCalled(); + }); + + it('skips unknown creators without throwing', async () => { + prismaMock.creatorProfile.findUnique.mockResolvedValueOnce(null); + + await processKeyDeprecationEvents([makeEvent()]); + + expect(prismaMock.creatorProfile.update).not.toHaveBeenCalled(); + }); + + it('ignores events of other types', async () => { + await processKeyDeprecationEvents([ + { + eventType: 'WHITELIST_ADDED', + txHash: 'tx-2', + eventIndex: 0, + } as unknown as KeyDeprecationChainEvent, + ]); + + expect(prismaMock.creatorProfile.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/indexer/key-deprecation-indexer.service.ts b/src/modules/indexer/key-deprecation-indexer.service.ts new file mode 100644 index 00000000..a4f8fbf9 --- /dev/null +++ b/src/modules/indexer/key-deprecation-indexer.service.ts @@ -0,0 +1,106 @@ +// src/modules/indexer/key-deprecation-indexer.service.ts +// Indexes KEY_DEPRECATED events emitted by the key contract, mirroring +// on-chain deprecation onto CreatorProfile so GET /keys/:keyId/deprecation, +// the key list/search endpoints, and the notification feed reflect it. + +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { + processIndexerChainEvents, + IndexerChainEvent, + getChainEventId, +} from '../../utils/indexer-event-processor.utils'; +import { invalidateCreatorDashboardCache } from '../creator/creator-dashboard.service'; +import { dispatchKeyDeprecationNotifications } from '../keys/key-deprecation.service'; + +/** + * Chain event emitted by the key contract when a creator's key is marked + * deprecated, optionally naming a successor key. + */ +export interface KeyDeprecationChainEvent extends IndexerChainEvent { + eventType: 'KEY_DEPRECATED'; + /** Creator profile id (key id) the event applies to. */ + creatorId: string; + /** Human-readable reason for the deprecation, if provided on-chain. */ + reason?: string; + /** Creator profile id of the designated successor key, if any. */ + successorKeyId?: string; +} + +/** + * Applies KEY_DEPRECATED events to the database. Idempotent: once a key is + * already marked deprecated (whether by this indexer or the admin buyback + * flow), a replayed event is a no-op — no duplicate DB writes and no + * duplicate holder notifications. + */ +export async function processKeyDeprecationEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (event.eventType !== 'KEY_DEPRECATED') { + return; + } + + const { creatorId, reason, successorKeyId } = + event as KeyDeprecationChainEvent; + if (!creatorId) { + logger.warn( + { eventId: getChainEventId(event) }, + 'Skipping key deprecation event with missing creatorId' + ); + return; + } + + const creator = await prisma.creatorProfile.findUnique({ + where: { id: creatorId }, + select: { id: true, deprecatedAt: true }, + }); + if (!creator) { + logger.warn( + { creatorId, txHash: event.txHash }, + 'Skipping key deprecation event for unknown creator' + ); + return; + } + + if (creator.deprecatedAt) { + // Already deprecated — replayed event or superseded by the admin + // buyback flow. Skip the write and the notification dispatch. + return; + } + + let resolvedSuccessorKeyId: string | null = null; + if (successorKeyId) { + const successor = await prisma.creatorProfile.findUnique({ + where: { id: successorKeyId }, + select: { id: true }, + }); + if (successor) { + resolvedSuccessorKeyId = successor.id; + } else { + logger.warn( + { creatorId, successorKeyId }, + 'Successor key referenced by deprecation event not found; storing without successor' + ); + } + } + + await prisma.creatorProfile.update({ + where: { id: creator.id }, + data: { + deprecatedAt: new Date(), + reason: reason ?? null, + successorKeyId: resolvedSuccessorKeyId, + }, + }); + + await invalidateCreatorDashboardCache(creator.id); + + await dispatchKeyDeprecationNotifications({ + keyId: creator.id, + eventId: getChainEventId(event), + reason, + successorKeyId: resolvedSuccessorKeyId ?? undefined, + }); + }); +} diff --git a/src/modules/keys/key-deprecation.service.test.ts b/src/modules/keys/key-deprecation.service.test.ts index 77b7a7ef..42f6fadb 100644 --- a/src/modules/keys/key-deprecation.service.test.ts +++ b/src/modules/keys/key-deprecation.service.test.ts @@ -24,6 +24,7 @@ const prismaMock = { }, creatorProfile: { findFirst: jest.fn(), + findUnique: jest.fn(), update: jest.fn(), }, $transaction: jest.fn(async (ops: unknown[]) => Promise.all(ops)), @@ -41,7 +42,11 @@ jest.mock('../admin/audit-log.service', () => ({ createAuditEntry: jest.fn(), })); -import { dispatchKeySunsetNotifications } from './key-deprecation.service'; +import { + dispatchKeySunsetNotifications, + getKeyDeprecationStatus, +} from './key-deprecation.service'; +import { KeyNotFoundError } from './key-fees.service'; describe('dispatchKeySunsetNotifications', () => { beforeEach(() => { @@ -176,3 +181,81 @@ describe('dispatchKeySunsetNotifications', () => { ); }); }); + +describe('getKeyDeprecationStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns active status with no successor for a non-deprecated key', async () => { + prismaMock.creatorProfile.findFirst.mockResolvedValue({ + id: 'creator-1', + deprecatedAt: null, + reason: null, + successorKeyId: null, + }); + + const result = await getKeyDeprecationStatus('creator-1'); + + expect(result).toEqual({ + keyId: 'creator-1', + status: 'active', + reason: null, + deprecatedAt: null, + successor: null, + }); + expect(prismaMock.creatorProfile.findUnique).not.toHaveBeenCalled(); + }); + + it('returns deprecated status with an embedded successor summary', async () => { + prismaMock.creatorProfile.findFirst.mockResolvedValue({ + id: 'creator-1', + deprecatedAt: new Date('2026-01-01T00:00:00.000Z'), + reason: 'sunset', + successorKeyId: 'creator-2', + }); + prismaMock.creatorProfile.findUnique.mockResolvedValue({ + id: 'creator-2', + displayName: 'Successor Key', + avatarUrl: 'https://example.com/avatar.png', + priceSnapshot: { currentPrice: BigInt(1000) }, + }); + + const result = await getKeyDeprecationStatus('creator-1'); + + expect(result).toEqual({ + keyId: 'creator-1', + status: 'deprecated', + reason: 'sunset', + deprecatedAt: '2026-01-01T00:00:00.000Z', + successor: { + id: 'creator-2', + name: 'Successor Key', + avatarUrl: 'https://example.com/avatar.png', + currentPrice: '1000', + }, + }); + }); + + it('returns a null successor when the designated successor key no longer exists', async () => { + prismaMock.creatorProfile.findFirst.mockResolvedValue({ + id: 'creator-1', + deprecatedAt: new Date('2026-01-01T00:00:00.000Z'), + reason: 'sunset', + successorKeyId: 'missing-key', + }); + prismaMock.creatorProfile.findUnique.mockResolvedValue(null); + + const result = await getKeyDeprecationStatus('creator-1'); + + expect(result.successor).toBeNull(); + }); + + it('throws KeyNotFoundError when the key does not exist', async () => { + prismaMock.creatorProfile.findFirst.mockResolvedValue(null); + + await expect(getKeyDeprecationStatus('missing')).rejects.toBeInstanceOf( + KeyNotFoundError + ); + }); +}); diff --git a/src/modules/keys/key-deprecation.service.ts b/src/modules/keys/key-deprecation.service.ts index 45fdbccf..dac4051f 100644 --- a/src/modules/keys/key-deprecation.service.ts +++ b/src/modules/keys/key-deprecation.service.ts @@ -376,6 +376,223 @@ export async function dispatchKeySunsetNotifications( }; } +export interface KeyDeprecationNotificationDispatchInput { + keyId: string; + eventId: string; + reason?: string; + successorKeyId?: string; + maxRetries?: number; + notificationDispatcher?: (payload: { + eventType: string; + keyId: string; + holderAddress: string; + eventId: string; + reason: string | null; + successorKeyId: string | null; + }) => Promise; +} + +export interface KeyDeprecationNotificationDispatchResult { + keyId: string; + eventId: string; + holdersNotified: number; + deliveredCount: number; + failedCount: number; + skippedCount: number; +} + +async function deliverKeyDeprecationNotification(payload: { + eventType: string; + keyId: string; + holderAddress: string; + eventId: string; + reason: string | null; + successorKeyId: string | null; +}): Promise { + logger.info( + payload, + 'Key deprecation notification dispatched to holder' + ); +} + +/** + * Notify every current holder of a key that it has been deprecated (e.g. by + * an indexed contract event). Mirrors {@link dispatchKeySunsetNotifications} + * but is not tied to a guaranteed-buyback payload, and dedupes per + * `eventId` via Redis so replaying the same chain event during re-indexing + * never notifies a holder twice. + */ +export async function dispatchKeyDeprecationNotifications( + input: KeyDeprecationNotificationDispatchInput +): Promise { + const holders = await prisma.keyOwnership.findMany({ + where: { + creatorId: input.keyId, + balance: { gt: 0 }, + }, + select: { + ownerAddress: true, + }, + }); + + const maxRetries = input.maxRetries ?? 3; + const redis = getRedis(); + const dedupeKey = REDIS_KEYS.keyDeprecationEvent(input.eventId); + const reason = input.reason ?? null; + const successorKeyId = input.successorKeyId ?? null; + let deliveredCount = 0; + let failedCount = 0; + let skippedCount = 0; + + for (const holder of holders) { + const holderAddress = holder.ownerAddress; + if (redis) { + const added = await redis.sadd(dedupeKey, holderAddress); + if (added === 0) { + skippedCount += 1; + continue; + } + } + + let attempts = 0; + let lastError: string | undefined; + let status: 'delivered' | 'failed' = 'failed'; + + for (let attempt = 1; attempt <= maxRetries; attempt += 1) { + attempts = attempt; + try { + await (input.notificationDispatcher ?? deliverKeyDeprecationNotification)({ + eventType: NOTIFICATION_TYPES.KEY_DEPRECATED, + keyId: input.keyId, + holderAddress, + eventId: input.eventId, + reason, + successorKeyId, + }); + status = 'delivered'; + deliveredCount += 1; + break; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + lastError = message; + logger.warn( + { + keyId: input.keyId, + holderAddress, + eventId: input.eventId, + attempt, + maxRetries, + error: message, + }, + 'Key deprecation notification delivery failed; retrying' + ); + if (attempt === maxRetries) { + failedCount += 1; + } + } + } + + await prisma.activityLog.create({ + data: { + type: 'key_deprecated_notification', + actor: 'system', + keyId: input.keyId, + target: holderAddress, + payload: { + eventId: input.eventId, + holderAddress, + keyId: input.keyId, + status, + attempts, + lastError, + reason, + successorKeyId, + }, + }, + }); + } + + return { + keyId: input.keyId, + eventId: input.eventId, + holdersNotified: holders.length, + deliveredCount, + failedCount, + skippedCount, + }; +} + +export interface KeyDeprecationSuccessorSummary { + id: string; + name: string | null; + avatarUrl: string | null; + currentPrice: string | null; +} + +export interface KeyDeprecationStatusResult { + keyId: string; + status: 'active' | 'deprecated'; + reason: string | null; + deprecatedAt: string | null; + successor: KeyDeprecationSuccessorSummary | null; +} + +/** + * Read-only deprecation status for a key: whether it is deprecated, why, + * when, and — when a successor key was designated — a summary of it. + * Backs GET /keys/:keyId/deprecation. + */ +export async function getKeyDeprecationStatus( + keyId: string +): Promise { + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + select: { + id: true, + deprecatedAt: true, + reason: true, + successorKeyId: true, + }, + }); + if (!creator) { + throw new KeyNotFoundError(keyId); + } + + let successor: KeyDeprecationSuccessorSummary | null = null; + if (creator.successorKeyId) { + const successorProfile = await prisma.creatorProfile.findUnique({ + where: { id: creator.successorKeyId }, + select: { + id: true, + displayName: true, + avatarUrl: true, + priceSnapshot: { select: { currentPrice: true } }, + }, + }); + if (successorProfile) { + successor = { + id: successorProfile.id, + name: successorProfile.displayName ?? null, + avatarUrl: successorProfile.avatarUrl ?? null, + currentPrice: successorProfile.priceSnapshot + ? successorProfile.priceSnapshot.currentPrice.toString() + : null, + }; + } + } + + return { + keyId: creator.id, + status: creator.deprecatedAt ? 'deprecated' : 'active', + reason: creator.reason ?? null, + deprecatedAt: creator.deprecatedAt + ? creator.deprecatedAt.toISOString() + : null, + successor, + }; +} + /** * Deprecate a key: verify the 2-of-3 multisig, store the buyback price and * expiry on the key record, and notify all holders (derived KEY_DEPRECATED diff --git a/src/modules/keys/key-search.service.test.ts b/src/modules/keys/key-search.service.test.ts index 0a42995e..8bfcc7b6 100644 --- a/src/modules/keys/key-search.service.test.ts +++ b/src/modules/keys/key-search.service.test.ts @@ -34,6 +34,7 @@ describe('key-search.service', () => { avatarUrl: null, currentPrice: 1000n, holderCount: 3n, + deprecatedAt: null, rank: 0.9, }, ]); @@ -46,7 +47,25 @@ describe('key-search.service', () => { avatarUrl: null, currentPrice: '1000', holderCount: 3, + deprecated: false, }, ]); }); + + it('flags a deprecated key in search results', async () => { + (prisma.$queryRaw as jest.Mock).mockResolvedValue([ + { + keyId: 'k2', + creatorName: 'Bob', + avatarUrl: null, + currentPrice: null, + holderCount: 0n, + deprecatedAt: new Date('2026-01-01T00:00:00.000Z'), + rank: 0.5, + }, + ]); + + const results = await searchKeys('bob'); + expect(results[0].deprecated).toBe(true); + }); }); diff --git a/src/modules/keys/key-search.service.ts b/src/modules/keys/key-search.service.ts index 15ff4560..133dda3e 100644 --- a/src/modules/keys/key-search.service.ts +++ b/src/modules/keys/key-search.service.ts @@ -11,6 +11,7 @@ export type KeySearchResult = { avatarUrl: string | null; currentPrice: string | null; holderCount: number; + deprecated: boolean; }; export class KeySearchQueryTooShortError extends Error { @@ -26,6 +27,7 @@ type SearchRow = { avatarUrl: string | null; currentPrice: bigint | null; holderCount: bigint; + deprecatedAt: Date | null; rank: number; }; @@ -44,6 +46,7 @@ export async function searchKeys(query: string): Promise { c.id AS "keyId", c."displayName" AS "creatorName", c."avatarUrl" AS "avatarUrl", + c."deprecatedAt" AS "deprecatedAt", s."currentPrice" AS "currentPrice", ( SELECT COUNT(*)::bigint @@ -68,5 +71,6 @@ export async function searchKeys(query: string): Promise { ? null : row.currentPrice.toString(), holderCount: Number(row.holderCount), + deprecated: row.deprecatedAt !== null, })); } diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 69b51ae7..fca9a40a 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -66,6 +66,7 @@ import { BuybackPriceNotSetError, BuybackWindowClosedError, deprecateKey, + getKeyDeprecationStatus, InsufficientPositionError, KeyAlreadyDeprecatedError, KeyNotDeprecatedError, @@ -1094,6 +1095,40 @@ router.all('/:keyId/deprecate', (_req, res) => { res.set('Allow', 'POST').sendStatus(405); }); +// ── GET /:keyId/deprecation ─────────────────────────────────── +// Public read of a key's deprecation status: status, reason, deprecatedAt, +// and an embedded summary of the designated successor key, if any. + +/** + * GET /api/v1/keys/:keyId/deprecation + * + * Returns the current deprecation status for a key. `status` is `active` + * when `deprecatedAt` is unset, otherwise `deprecated`. `successor` embeds + * `{ id, name, avatarUrl, currentPrice }` when a successor key was + * designated and still exists; otherwise `null`. + */ +router.get('/:keyId/deprecation', async (req, res, next) => { + try { + const keyId = String(req.params.keyId); + const result = await getKeyDeprecationStatus(keyId); + sendSuccess(res, result, 200); + } catch (error) { + if (error instanceof KeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + logger.error( + { error, keyId: req.params.keyId }, + 'Get key deprecation status failed' + ); + next(error); + } +}); + +router.all('/:keyId/deprecation', (_req, res) => { + res.set('Allow', 'GET').sendStatus(405); +}); + // ── POST /:keyId/buyback ───────────────────────────────────── // Holder exit at the guaranteed buyback price on a deprecated key. // Rejected with 410 Gone after the buyback window expires. diff --git a/src/modules/notifications/notification.service.ts b/src/modules/notifications/notification.service.ts index d55b29b0..21681c86 100644 --- a/src/modules/notifications/notification.service.ts +++ b/src/modules/notifications/notification.service.ts @@ -126,6 +126,8 @@ async function buildKeyDeprecated( deprecatedAt: true, buybackPriceXlm: true, buybackExpiresAt: true, + reason: true, + successorKeyId: true, }, }); @@ -139,6 +141,8 @@ async function buildKeyDeprecated( read: isRead(createdAt, lastReadAt), payload: { keyId: key.id, + reason: key.reason ?? null, + successorKeyId: key.successorKeyId ?? null, buybackPriceXlm: key.buybackPriceXlm !== null && key.buybackPriceXlm !== undefined diff --git a/src/types/profile.types.ts b/src/types/profile.types.ts index 05ee2faf..37e17ca5 100644 --- a/src/types/profile.types.ts +++ b/src/types/profile.types.ts @@ -41,6 +41,8 @@ export interface CreatorProfile { updatedAt: Date; /** Price read model — null when no trade has been recorded yet. */ priceSnapshot?: CreatorPriceSnapshot | null; + /** When the key was deprecated (#882); null/undefined while active. */ + deprecatedAt?: Date | null; } export interface UserSettings {