diff --git a/src/modules/creators/creator-public-profile.controller.test.ts b/src/modules/creators/creator-public-profile.controller.test.ts new file mode 100644 index 00000000..69efde22 --- /dev/null +++ b/src/modules/creators/creator-public-profile.controller.test.ts @@ -0,0 +1,174 @@ +// src/modules/creators/creator-public-profile.controller.test.ts +// Unit tests for public creator profile controller (#1009). + +import { Request, Response } from 'express'; +import { + httpGetCreatorPublicProfile, + httpGetCreatorIssuedKeys, +} from './creator-public-profile.controller'; +import * as service from './creator-public-profile.service'; + +jest.mock('./creator-public-profile.service'); + +describe('Creator Public Profile Controllers (#1009)', () => { + const validWallet = 'G' + 'A'.repeat(55); + const unregisteredWallet = 'G' + 'B'.repeat(55); + + let req: Partial; + let res: Partial; + let next: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + req = { + params: {}, + query: {}, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + }; + next = jest.fn(); + }); + + describe('httpGetCreatorPublicProfile', () => { + it('returns 400 validation error when wallet address is invalid format', async () => { + req.params = { wallet: 'invalid-wallet-format' }; + + await httpGetCreatorPublicProfile(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'VALIDATION_ERROR', + message: 'Invalid wallet address', + }), + }) + ); + }); + + it('returns 404 when wallet is unregistered (not found in DB)', async () => { + req.params = { wallet: unregisteredWallet }; + (service.getCreatorPublicProfile as jest.Mock).mockResolvedValue(null); + + await httpGetCreatorPublicProfile(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'NOT_FOUND', + }), + }) + ); + }); + + it('returns 200 with profile data for registered wallet', async () => { + req.params = { wallet: validWallet }; + const mockProfile = { + wallet: validWallet, + memberSince: '2026-01-01T00:00:00.000Z', + totalKeys: 2, + totalVolume: '5000', + avgRating: 4.8, + isVerified: true, + handle: 'test_creator', + displayName: 'Test Creator', + bio: 'Bio', + avatarUrl: null, + stats: { + totalHolders: 10, + totalTradingVolume: '5000', + followersCount: 20, + }, + }; + (service.getCreatorPublicProfile as jest.Mock).mockResolvedValue(mockProfile); + + await httpGetCreatorPublicProfile(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: mockProfile, + }) + ); + expect(res.set).toHaveBeenCalledWith('X-Response-Timestamp', expect.any(String)); + }); + }); + + describe('httpGetCreatorIssuedKeys', () => { + it('returns 400 when pagination query parameter types are invalid', async () => { + req.params = { wallet: validWallet }; + req.query = { limit: ['10', '20'] as any }; + + await httpGetCreatorIssuedKeys(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'VALIDATION_ERROR', + message: 'Invalid keys pagination parameters', + }), + }) + ); + }); + + it('returns 404 when wallet is not found', async () => { + req.params = { wallet: unregisteredWallet }; + (service.getCreatorDeployedKeys as jest.Mock).mockResolvedValue(null); + + await httpGetCreatorIssuedKeys(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 200 with paginated keys in deployment order', async () => { + req.params = { wallet: validWallet }; + req.query = { limit: '20' }; + const mockPage = { + items: [ + { + id: 'key-1', + handle: 'key1', + displayName: 'Key 1', + bio: null, + avatarUrl: null, + isVerified: true, + holderCount: 5, + totalTradingVolume: '1000', + status: 'ACTIVE', + price: '100', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + nextCursor: null, + hasMore: false, + limit: 20, + stats: { + totalKeys: 1, + totalHolders: 5, + totalTradingVolume: '1000', + }, + }; + (service.getCreatorDeployedKeys as jest.Mock).mockResolvedValue(mockPage); + + await httpGetCreatorIssuedKeys(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: mockPage, + }) + ); + }); + }); +}); diff --git a/src/modules/creators/creator-public-profile.controller.ts b/src/modules/creators/creator-public-profile.controller.ts index 3d5a16af..3d6d6fcb 100644 --- a/src/modules/creators/creator-public-profile.controller.ts +++ b/src/modules/creators/creator-public-profile.controller.ts @@ -1,5 +1,5 @@ // src/modules/creators/creator-public-profile.controller.ts -// HTTP controllers for the public creator profile endpoints (#900). +// HTTP controllers for the public creator profile endpoints (#1009). import { AsyncController } from '../../types/auth.types'; import { @@ -10,16 +10,17 @@ import { import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; import { getCreatorPublicProfile, - getCreatorIssuedKeys, + getCreatorDeployedKeys, } from './creator-public-profile.service'; +const StellarWalletAddressRegex = /^G[A-Z2-7]{55}$/; + /** - * GET /api/v1/creators/:id - * - * Returns the public profile for a creator together with social stats - * (total holders, total trading volume, followers count). + * GET /api/v1/creators/:wallet * - * No authentication required. Resolves by creator ID or handle. + * Returns the public profile for a creator wallet with memberSince, totalKeys, + * totalVolume, avgRating, and stats aggregated across all their deployed keys. + * Verified badge derived from KYC status. Returns 404 for unregistered wallets. */ export const httpGetCreatorPublicProfile: AsyncController = async ( req, @@ -27,10 +28,16 @@ export const httpGetCreatorPublicProfile: AsyncController = async ( next ) => { try { - const rawId = req.params.id; - const creatorId = Array.isArray(rawId) ? rawId[0] : rawId; + const rawWallet = req.params.wallet ?? req.params.id; + const wallet = Array.isArray(rawWallet) ? rawWallet[0] : rawWallet; + + if (!wallet || !StellarWalletAddressRegex.test(wallet)) { + return sendValidationError(res, 'Invalid wallet address', [ + { field: 'wallet', message: 'A valid Stellar wallet address is required' }, + ]); + } - const profile = await getCreatorPublicProfile(creatorId); + const profile = await getCreatorPublicProfile(wallet); if (!profile) { return sendNotFound(res, 'Creator'); } @@ -43,17 +50,10 @@ export const httpGetCreatorPublicProfile: AsyncController = async ( }; /** - * GET /api/v1/creators/:id/keys + * GET /api/v1/creators/:wallet/keys * - * Returns a cursor-paginated list of keys (CreatorProfile records) issued by - * the same user who owns the given creator profile, together with aggregate - * stats across all their keys. - * - * Query params: - * - limit (optional, 1–100, default 20) - * - cursor (optional, opaque pagination cursor) - * - * No authentication required. Resolves by creator ID or handle. + * Returns a cursor-paginated list of deployed keys for a creator wallet in + * deployment order with status and price. */ export const httpGetCreatorIssuedKeys: AsyncController = async ( req, @@ -61,13 +61,18 @@ export const httpGetCreatorIssuedKeys: AsyncController = async ( next ) => { try { - const rawId = req.params.id; - const creatorId = Array.isArray(rawId) ? rawId[0] : rawId; + const rawWallet = req.params.wallet ?? req.params.id; + const wallet = Array.isArray(rawWallet) ? rawWallet[0] : rawWallet; + + if (!wallet || !StellarWalletAddressRegex.test(wallet)) { + return sendValidationError(res, 'Invalid wallet address', [ + { field: 'wallet', message: 'A valid Stellar wallet address is required' }, + ]); + } const rawLimit = req.query.limit; const rawCursor = req.query.cursor; - // Reject repeated query params if ( (rawLimit !== undefined && typeof rawLimit !== 'string') || (rawCursor !== undefined && typeof rawCursor !== 'string') @@ -84,8 +89,8 @@ export const httpGetCreatorIssuedKeys: AsyncController = async ( ); } - const page = await getCreatorIssuedKeys( - creatorId, + const page = await getCreatorDeployedKeys( + wallet, rawLimit, rawCursor ); diff --git a/src/modules/creators/creator-public-profile.integration.test.ts b/src/modules/creators/creator-public-profile.integration.test.ts new file mode 100644 index 00000000..12322c06 --- /dev/null +++ b/src/modules/creators/creator-public-profile.integration.test.ts @@ -0,0 +1,154 @@ +// src/modules/creators/creator-public-profile.integration.test.ts +// Integration tests for creator public profile & deployed keys API (#1009). + +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +describe('Creator Public Profile & Deployed Keys API (#1009)', () => { + const walletAddress = 'G' + 'A'.repeat(55); + const unregisteredWallet = 'G' + 'B'.repeat(55); + const userId = 'user-public-profile-test'; + const creatorId1 = 'creator-profile-1'; + const creatorId2 = 'creator-profile-2'; + + beforeAll(async () => { + // Clean up test data + await prisma.trade.deleteMany({ where: { creatorId: { in: [creatorId1, creatorId2] } } }); + await prisma.keyOwnership.deleteMany({ where: { creatorId: { in: [creatorId1, creatorId2] } } }); + await prisma.creatorProfile.deleteMany({ where: { id: { in: [creatorId1, creatorId2] } } }); + await prisma.stellarWallet.deleteMany({ where: { address: walletAddress } }); + await prisma.kycRecord.deleteMany({ where: { walletAddress } }); + await prisma.user.deleteMany({ where: { id: userId } }); + + // Create user and wallet + await prisma.user.create({ + data: { + id: userId, + email: 'publicprofile@example.test', + passwordHash: 'hash', + firstName: 'Public', + lastName: 'Creator', + }, + }); + + await prisma.stellarWallet.create({ + data: { + id: 'wallet-public-1', + address: walletAddress, + userId, + }, + }); + + // Create two deployed keys (creator profiles) in chronological order + await prisma.creatorProfile.create({ + data: { + id: creatorId1, + userId, + handle: 'creator_first', + displayName: 'First Key Creator', + isVerified: false, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }, + }); + + await prisma.creatorProfile.create({ + data: { + id: creatorId2, + userId, + handle: 'creator_second', + displayName: 'Second Key Creator', + isVerified: false, + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }, + }); + + // Seed trade and ownership to test aggregate stats + await prisma.keyOwnership.create({ + data: { + id: 'ownership-1', + creatorId: creatorId1, + ownerAddress: 'GBHOLDER222222222222222222222222222222222222222222222222', + balance: 10, + }, + }); + + await prisma.trade.create({ + data: { + id: 'trade-1', + creatorId: creatorId1, + buyer: 'GBHOLDER222222222222222222222222222222222222222222222222', + timestamp: new Date(), + price: '1000000', + quantity: '5', + txHash: 'tx-hash-1', + ledger: 100, + }, + }); + }); + + afterAll(async () => { + await prisma.trade.deleteMany({ where: { creatorId: { in: [creatorId1, creatorId2] } } }); + await prisma.keyOwnership.deleteMany({ where: { creatorId: { in: [creatorId1, creatorId2] } } }); + await prisma.creatorProfile.deleteMany({ where: { id: { in: [creatorId1, creatorId2] } } }); + await prisma.stellarWallet.deleteMany({ where: { address: walletAddress } }); + await prisma.kycRecord.deleteMany({ where: { walletAddress } }); + await prisma.user.deleteMany({ where: { id: userId } }); + }); + + it('returns 404 for unregistered wallets', async () => { + const res = await supertest(app).get(`/api/v1/creators/${unregisteredWallet}`); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + + it('returns profile with member since, total keys, total volume, and cache headers', async () => { + const res = await supertest(app).get(`/api/v1/creators/${walletAddress}`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toMatchObject({ + wallet: walletAddress, + totalKeys: 2, + totalVolume: '5000000', // 1,000,000 * 5 + }); + expect(res.body.data.memberSince).toBeDefined(); + expect(res.headers['cache-control']).toContain('max-age=300'); + }); + + it('derives verified flag correctly from KYC status', async () => { + // Before KYC approval + let res = await supertest(app).get(`/api/v1/creators/${walletAddress}`); + expect(res.status).toBe(200); + expect(res.body.data.isVerified).toBe(false); + + // Approve KYC + await prisma.kycRecord.create({ + data: { + walletAddress, + status: 'APPROVED', + approvedAt: new Date(), + }, + }); + + // After KYC approval + res = await supertest(app).get(`/api/v1/creators/${walletAddress}`); + expect(res.status).toBe(200); + expect(res.body.data.isVerified).toBe(true); + + // Cleanup KYC for other tests + await prisma.kycRecord.deleteMany({ where: { walletAddress } }); + }); + + it('returns paginated deployed keys in deployment order (oldest first) with status and price', async () => { + const res = await supertest(app).get(`/api/v1/creators/${walletAddress}/keys`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.items).toHaveLength(2); + // Deployment order: creatorId1 (Jan 1) should come before creatorId2 (Jan 2) + expect(res.body.data.items[0].id).toBe(creatorId1); + expect(res.body.data.items[1].id).toBe(creatorId2); + expect(res.body.data.items[0]).toHaveProperty('status'); + expect(res.body.data.items[0]).toHaveProperty('price'); + expect(res.headers['cache-control']).toContain('max-age=300'); + }); +}); diff --git a/src/modules/creators/creator-public-profile.service.test.ts b/src/modules/creators/creator-public-profile.service.test.ts new file mode 100644 index 00000000..ece5c339 --- /dev/null +++ b/src/modules/creators/creator-public-profile.service.test.ts @@ -0,0 +1,318 @@ +// src/modules/creators/creator-public-profile.service.test.ts +// Unit tests for public creator profile service (#1009). + +const mockPrisma = { + stellarWallet: { + findUnique: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + kycRecord: { + findUnique: jest.fn(), + }, + keyOwnership: { + count: jest.fn(), + }, + trade: { + findMany: jest.fn(), + }, + creatorReputation: { + findMany: jest.fn(), + }, + creatorPriceSnapshot: { + findUnique: jest.fn(), + }, +}; + +const mockCache = { + cacheGetJson: jest.fn(), + cacheSetJson: jest.fn(), + cacheInvalidate: jest.fn(), +}; + +jest.mock('../../utils/prisma.utils', () => ({ prisma: mockPrisma })); +jest.mock('../../utils/redis.utils', () => mockCache); + +import { + getCreatorPublicProfile, + getCreatorDeployedKeys, +} from './creator-public-profile.service'; + +describe('Creator Public Profile Service (#1009)', () => { + const wallet = 'G' + 'A'.repeat(55); + const unregisteredWallet = 'G' + 'B'.repeat(55); + const date1 = new Date('2026-01-01T00:00:00.000Z'); + const date2 = new Date('2026-02-01T00:00:00.000Z'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getCreatorPublicProfile', () => { + it('returns 404 (null) for unregistered wallet addresses', async () => { + mockCache.cacheGetJson.mockResolvedValue(null); + mockPrisma.stellarWallet.findUnique.mockResolvedValue(null); + mockPrisma.creatorProfile.findMany.mockResolvedValue([]); + mockPrisma.kycRecord.findUnique.mockResolvedValue(null); + + const result = await getCreatorPublicProfile(unregisteredWallet); + expect(result).toBeNull(); + }); + + it('serves cached profile when available in Redis cache', async () => { + const cachedData = { + wallet, + memberSince: date1.toISOString(), + totalKeys: 1, + totalVolume: '1000', + avgRating: 4.5, + isVerified: true, + handle: 'creator1', + displayName: 'Creator One', + bio: 'Bio', + avatarUrl: null, + stats: { + totalHolders: 5, + totalTradingVolume: '1000', + followersCount: 10, + }, + }; + mockCache.cacheGetJson.mockResolvedValue(cachedData); + + const result = await getCreatorPublicProfile(wallet); + expect(result).toEqual(cachedData); + expect(mockPrisma.stellarWallet.findUnique).not.toHaveBeenCalled(); + }); + + it('aggregates stats correctly across all creator keys and caches for 5 minutes', async () => { + mockCache.cacheGetJson.mockResolvedValue(null); + mockPrisma.stellarWallet.findUnique.mockResolvedValue({ + createdAt: date1, + userId: 'user-1', + }); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { + id: 'key-1', + userId: 'user-1', + handle: 'first_key', + displayName: 'First Key', + bio: 'Bio 1', + avatarUrl: 'https://example.com/1.png', + isVerified: false, + tradingPaused: false, + deprecatedAt: null, + followersCount: 10, + createdAt: date1, + }, + { + id: 'key-2', + userId: 'user-1', + handle: 'second_key', + displayName: 'Second Key', + bio: 'Bio 2', + avatarUrl: 'https://example.com/2.png', + isVerified: false, + tradingPaused: false, + deprecatedAt: null, + followersCount: 15, + createdAt: date2, + }, + ]); + mockPrisma.kycRecord.findUnique.mockResolvedValue(null); + mockPrisma.keyOwnership.count.mockResolvedValue(8); + mockPrisma.trade.findMany.mockResolvedValue([ + { price: '100', quantity: '5' }, + { price: '200', quantity: '2' }, + ]); + mockPrisma.creatorReputation.findMany.mockResolvedValue([ + { currentScore: 800 }, + { currentScore: 900 }, + ]); + + const result = await getCreatorPublicProfile(wallet); + + expect(result).not.toBeNull(); + expect(result?.wallet).toBe(wallet); + expect(result?.memberSince).toBe(date1.toISOString()); + expect(result?.totalKeys).toBe(2); + // Total volume = (100 * 5) + (200 * 2) = 500 + 400 = 900 + expect(result?.totalVolume).toBe('900'); + // Avg score = (800 + 900) / 2 = 850. 850 / 200 = 4.25 -> 4.3 or 4.2 + expect(result?.avgRating).toBe(4.3); + expect(result?.isVerified).toBe(false); + expect(result?.stats).toEqual({ + totalHolders: 8, + totalTradingVolume: '900', + followersCount: 25, + }); + + // Verify cache set with 5-minute TTL (300 seconds) + expect(mockCache.cacheSetJson).toHaveBeenCalledWith( + `creator-public-profile:v1:${wallet}`, + expect.objectContaining({ wallet, totalKeys: 2, totalVolume: '900' }), + 300 + ); + }); + + it('derives verified badge flag correctly from KYC status', async () => { + mockCache.cacheGetJson.mockResolvedValue(null); + mockPrisma.stellarWallet.findUnique.mockResolvedValue({ + createdAt: date1, + userId: 'user-1', + }); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { + id: 'key-1', + userId: 'user-1', + handle: 'verified_creator', + displayName: 'Verified Creator', + bio: null, + avatarUrl: null, + isVerified: false, + tradingPaused: false, + deprecatedAt: null, + followersCount: 0, + createdAt: date1, + }, + ]); + mockPrisma.kycRecord.findUnique.mockResolvedValue({ + status: 'APPROVED', + }); + mockPrisma.keyOwnership.count.mockResolvedValue(0); + mockPrisma.trade.findMany.mockResolvedValue([]); + mockPrisma.creatorReputation.findMany.mockResolvedValue([]); + + const result = await getCreatorPublicProfile(wallet); + expect(result?.isVerified).toBe(true); + }); + }); + + describe('getCreatorDeployedKeys', () => { + it('returns 404 (null) for unregistered wallet addresses', async () => { + mockPrisma.stellarWallet.findUnique.mockResolvedValue(null); + mockPrisma.creatorProfile.findMany.mockResolvedValue([]); + mockPrisma.kycRecord.findUnique.mockResolvedValue(null); + + const result = await getCreatorDeployedKeys(unregisteredWallet); + expect(result).toBeNull(); + }); + + it('returns deployed keys in deployment order with status and price', async () => { + mockPrisma.stellarWallet.findUnique.mockResolvedValue({ + createdAt: date1, + userId: 'user-1', + }); + // The service queries profiles with orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] + mockPrisma.creatorProfile.findMany + // First call in resolveWalletProfiles + .mockResolvedValueOnce([ + { id: 'key-1', userId: 'user-1', createdAt: date1 }, + { id: 'key-2', userId: 'user-1', createdAt: date2 }, + ]) + // Second call in getCreatorDeployedKeys (page query) + .mockResolvedValueOnce([ + { + id: 'key-1', + handle: 'key1', + displayName: 'Key 1', + bio: 'Bio 1', + avatarUrl: null, + isVerified: true, + tradingPaused: false, + deprecatedAt: null, + createdAt: date1, + }, + { + id: 'key-2', + handle: 'key2', + displayName: 'Key 2', + bio: 'Bio 2', + avatarUrl: null, + isVerified: false, + tradingPaused: true, + deprecatedAt: null, + createdAt: date2, + }, + ]) + // Third call in getCreatorDeployedKeys (allIds query) + .mockResolvedValueOnce([ + { id: 'key-1' }, + { id: 'key-2' }, + ]); + + mockPrisma.kycRecord.findUnique.mockResolvedValue({ status: 'APPROVED' }); + + // Mock keyOwnership, trade, and priceSnapshot for each key + mockPrisma.keyOwnership.count + .mockResolvedValueOnce(3) // key-1 holder count + .mockResolvedValueOnce(2) // key-2 holder count + .mockResolvedValueOnce(5); // total holders across all keys + + mockPrisma.trade.findMany + .mockResolvedValueOnce([{ price: '50', quantity: '2' }]) // key-1 trades + .mockResolvedValueOnce([]) // key-2 trades + .mockResolvedValueOnce([{ price: '50', quantity: '2' }]); // all trades + + mockPrisma.creatorPriceSnapshot.findUnique + .mockResolvedValueOnce({ currentPrice: 1500000n }) // key-1 price + .mockResolvedValueOnce(null); // key-2 price + + const page = await getCreatorDeployedKeys(wallet, '10'); + + expect(page).not.toBeNull(); + expect(page?.items).toHaveLength(2); + expect(page?.items[0]).toEqual({ + id: 'key-1', + handle: 'key1', + displayName: 'Key 1', + bio: 'Bio 1', + avatarUrl: null, + isVerified: true, + holderCount: 3, + totalTradingVolume: '100', + status: 'ACTIVE', + price: '1500000', + createdAt: date1.toISOString(), + }); + expect(page?.items[1]).toEqual({ + id: 'key-2', + handle: 'key2', + displayName: 'Key 2', + bio: 'Bio 2', + avatarUrl: null, + isVerified: false, + holderCount: 2, + totalTradingVolume: '0', + status: 'PAUSED', + price: '0', + createdAt: date2.toISOString(), + }); + expect(page?.stats).toEqual({ + totalKeys: 2, + totalHolders: 5, + totalTradingVolume: '100', + }); + }); + + it('validates pagination limit parameter', async () => { + mockPrisma.stellarWallet.findUnique.mockResolvedValue({ + createdAt: date1, + userId: 'user-1', + }); + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: 'key-1', userId: 'user-1', createdAt: date1 }, + ]); + + await expect(getCreatorDeployedKeys(wallet, 'invalid')).rejects.toThrow( + 'Invalid keys pagination limit' + ); + await expect(getCreatorDeployedKeys(wallet, '0')).rejects.toThrow( + 'Invalid keys pagination limit' + ); + await expect(getCreatorDeployedKeys(wallet, '101')).rejects.toThrow( + 'Invalid keys pagination limit' + ); + }); + }); +}); diff --git a/src/modules/creators/creator-public-profile.service.ts b/src/modules/creators/creator-public-profile.service.ts index 9ea6734c..aa0d53b4 100644 --- a/src/modules/creators/creator-public-profile.service.ts +++ b/src/modules/creators/creator-public-profile.service.ts @@ -1,46 +1,36 @@ // src/modules/creators/creator-public-profile.service.ts -// Service layer for public creator profile endpoints (#900). +// Service layer for public creator profile endpoints (#1009). import { prisma } from '../../utils/prisma.utils'; import { encodeCursor, decodeCursor } from '../../utils/cursor.utils'; -import { resolvePinataGatewayUrl } from '../keys/key-metadata-sync.service'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; const DEFAULT_KEYS_PAGE_SIZE = 20; const MAX_KEYS_PAGE_SIZE = 100; +const PROFILE_CACHE_TTL_SECONDS = 300; // 5 minutes +const PROFILE_CACHE_PREFIX = 'creator-public-profile:v1:'; -// ── Types ───────────────────────────────────────────────────────────────────── - -export interface CreatorPublicSocialStats { - /** Number of unique wallets currently holding keys with balance > 0. */ +export interface CreatorPublicProfileStats { totalHolders: number; - /** Aggregated trading volume (sum of price * quantity) in stroops, as string. */ totalTradingVolume: string; - /** Number of followers for this creator profile. */ followersCount: number; } -export interface CreatorPublicProfileWithStats { - id: string; - handle: string; - displayName: string; +export interface CreatorPublicProfileResult { + wallet: string; + memberSince: string; + totalKeys: number; + totalVolume: string; + avgRating: number; + isVerified: boolean; + handle: string | null; + displayName: string | null; bio: string | null; avatarUrl: string | null; - avatarCid?: string | null; - twitter?: string | null; - website?: string | null; - discord?: string | null; - socialLinks?: { - twitter: string | null; - website: string | null; - discord: string | null; - }; - isVerified: boolean; - createdAt: string; - updatedAt: string; - stats: CreatorPublicSocialStats; + stats: CreatorPublicProfileStats; } -export interface CreatorIssuedKey { +export interface CreatorDeployedKeyItem { id: string; handle: string; displayName: string; @@ -49,20 +39,21 @@ export interface CreatorIssuedKey { isVerified: boolean; holderCount: number; totalTradingVolume: string; + status: string; + price: string; createdAt: string; } -interface IssuedKeysCursor { +interface DeployedKeysCursor { id: string; createdAt: string; } -export interface CreatorIssuedKeysPage { - items: CreatorIssuedKey[]; +export interface CreatorDeployedKeysPage { + items: CreatorDeployedKeyItem[]; nextCursor: string | null; hasMore: boolean; limit: number; - /** Aggregate stats across all keys for this creator (regardless of page). */ stats: { totalKeys: number; totalHolders: number; @@ -70,39 +61,27 @@ export interface CreatorIssuedKeysPage { }; } -// ── Helpers ─────────────────────────────────────────────────────────────────── - /** - * Resolves a creator profile by ID or handle. - * Returns null when not found. + * Resolves creator profiles and stellar wallet for a given wallet address. */ -async function resolveCreatorProfile( - creatorId: string -): Promise<{ - id: string; - handle: string; - displayName: string; - bio: string | null; - avatarUrl: string | null; - avatarCid: string | null; - twitter: string | null; - website: string | null; - discord: string | null; - isVerified: boolean; - followersCount: number; - createdAt: Date; - updatedAt: Date; -} | null> { - return prisma.creatorProfile.findFirst({ +async function resolveWalletProfiles(wallet: string) { + const stellarWallet = await prisma.stellarWallet.findUnique({ + where: { address: wallet }, + select: { createdAt: true, userId: true }, + }); + + const profiles = await prisma.creatorProfile.findMany({ where: { OR: [ - { id: creatorId }, - { handle: creatorId }, - { user: { stellarWallet: { address: creatorId } } }, + ...(stellarWallet ? [{ userId: stellarWallet.userId }] : []), + { id: wallet }, + { handle: wallet }, + { user: { stellarWallet: { address: wallet } } }, ], }, select: { id: true, + userId: true, handle: true, displayName: true, bio: true, @@ -112,109 +91,134 @@ async function resolveCreatorProfile( website: true, discord: true, isVerified: true, + tradingPaused: true, + deprecatedAt: true, followersCount: true, createdAt: true, - updatedAt: true, }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], }); -} -/** - * Computes holder count and total trading volume for a given creator profile ID. - */ -async function computeCreatorKeyStats( - creatorProfileId: string -): Promise<{ holderCount: number; totalTradingVolume: bigint }> { - const [holderCount, trades] = await Promise.all([ - prisma.keyOwnership.count({ - where: { creatorId: creatorProfileId, balance: { gt: 0 } }, - }), - prisma.trade.findMany({ - where: { creatorId: creatorProfileId }, - select: { price: true, quantity: true }, - }), - ]); + const kycRecord = await prisma.kycRecord.findUnique({ + where: { walletAddress: wallet }, + select: { status: true }, + }); - let totalTradingVolume = 0n; - for (const trade of trades) { - try { - totalTradingVolume += - BigInt(trade.price) * BigInt(Math.trunc(Number(trade.quantity))); - } catch { - // Skip malformed rows - } + if (!stellarWallet && profiles.length === 0) { + return null; } - return { holderCount, totalTradingVolume }; -} + const isVerified = + (kycRecord && kycRecord.status.toUpperCase() === 'APPROVED') || + profiles.some(p => p.isVerified); -// ── Public service functions ────────────────────────────────────────────────── + return { + wallet, + stellarWallet, + profiles, + isVerified, + }; +} -/** - * Returns the public profile for a creator including social stats. - * - * Resolves by creator ID, handle, or wallet. Returns null when the creator does not - * exist so callers can send a 404. - */ export async function getCreatorPublicProfile( - creatorId: string -): Promise { - const profile = await resolveCreatorProfile(creatorId); - if (!profile) return null; + wallet: string +): Promise { + const cacheKey = `${PROFILE_CACHE_PREFIX}${wallet}`; + const cached = await cacheGetJson(cacheKey); + if (cached) { + return cached; + } - const { holderCount, totalTradingVolume } = - await computeCreatorKeyStats(profile.id); + const resolved = await resolveWalletProfiles(wallet); + if (!resolved) return null; - const resolvedAvatarUrl = - resolvePinataGatewayUrl(profile.avatarCid) || profile.avatarUrl; + const { profiles, isVerified } = resolved; + const creatorIds = profiles.map(p => p.id); - return { - id: profile.id, - handle: profile.handle, - displayName: profile.displayName, - bio: profile.bio, - avatarUrl: resolvedAvatarUrl, - avatarCid: profile.avatarCid, - twitter: profile.twitter, - website: profile.website, - discord: profile.discord, - socialLinks: { - twitter: profile.twitter, - website: profile.website, - discord: profile.discord, - }, - isVerified: profile.isVerified, - createdAt: profile.createdAt.toISOString(), - updatedAt: profile.updatedAt.toISOString(), + let totalHolders = 0; + let totalTradingVolume = 0n; + let totalFollowers = 0; + let totalScore = 0; + + if (creatorIds.length > 0) { + const [holderCount, trades, reputations] = await Promise.all([ + prisma.keyOwnership.count({ + where: { creatorId: { in: creatorIds }, balance: { gt: 0 } }, + }), + prisma.trade.findMany({ + where: { creatorId: { in: creatorIds } }, + select: { price: true, quantity: true }, + }), + prisma.creatorReputation.findMany({ + where: { creatorId: { in: creatorIds } }, + select: { currentScore: true }, + }), + ]); + + totalHolders = holderCount; + for (const trade of trades) { + try { + totalTradingVolume += + BigInt(trade.price) * BigInt(Math.trunc(Number(trade.quantity))); + } catch { + // skip + } + } + for (const rep of reputations) { + totalScore += rep.currentScore; + } + for (const p of profiles) { + totalFollowers += p.followersCount; + } + } + + const avgRating = + creatorIds.length > 0 + ? Number(((totalScore / creatorIds.length) / 200).toFixed(1)) + : 0; + const clampedRating = Math.min(5.0, Math.max(0.0, avgRating)); + + const dates = [ + resolved.stellarWallet?.createdAt, + ...profiles.map(p => p.createdAt), + ].filter(Boolean) as Date[]; + const memberSince = + dates.length > 0 + ? new Date(Math.min(...dates.map(d => d.getTime()))).toISOString() + : new Date().toISOString(); + + const primaryProfile = profiles[0] || null; + + const result: CreatorPublicProfileResult = { + wallet, + memberSince, + totalKeys: profiles.length, + totalVolume: totalTradingVolume.toString(), + avgRating: clampedRating, + isVerified, + handle: primaryProfile?.handle ?? null, + displayName: primaryProfile?.displayName ?? null, + bio: primaryProfile?.bio ?? null, + avatarUrl: primaryProfile?.avatarUrl ?? null, stats: { - totalHolders: holderCount, + totalHolders, totalTradingVolume: totalTradingVolume.toString(), - followersCount: profile.followersCount, + followersCount: totalFollowers, }, }; + + await cacheSetJson(cacheKey, result, PROFILE_CACHE_TTL_SECONDS); + return result; } -/** - * Returns a cursor-paginated list of keys (CreatorProfile records) issued by - * the same user who owns the given creator profile, together with aggregate - * stats across all keys. - * - * Resolves the creator by ID or handle first. Returns null when the creator - * does not exist. - */ -export async function getCreatorIssuedKeys( - creatorId: string, +export async function getCreatorDeployedKeys( + wallet: string, rawLimit?: string, rawCursor?: string -): Promise { - // 1. Resolve the creator profile to get userId - const profile = await prisma.creatorProfile.findFirst({ - where: { OR: [{ id: creatorId }, { handle: creatorId }] }, - select: { id: true, userId: true }, - }); - if (!profile) return null; +): Promise { + const resolved = await resolveWalletProfiles(wallet); + if (!resolved) return null; - // 2. Validate and parse the page size const requestedLimit = rawLimit === undefined ? DEFAULT_KEYS_PAGE_SIZE : Number(rawLimit); if ( @@ -225,11 +229,10 @@ export async function getCreatorIssuedKeys( throw new Error('Invalid keys pagination limit'); } - // 3. Decode the cursor when provided - let cursor: IssuedKeysCursor | undefined; + let cursor: DeployedKeysCursor | undefined; if (rawCursor !== undefined) { try { - const decoded = decodeCursor(rawCursor); + const decoded = decodeCursor(rawCursor); if ( typeof decoded.id !== 'string' || !decoded.id || @@ -244,23 +247,36 @@ export async function getCreatorIssuedKeys( } } - // 4. Fetch page: all CreatorProfile records belonging to the same user + const userId = resolved.stellarWallet?.userId; + const profileIds = resolved.profiles.map(p => p.id); + + if (profileIds.length === 0 && !userId) { + return { + items: [], + nextCursor: null, + hasMore: false, + limit: requestedLimit, + stats: { totalKeys: 0, totalHolders: 0, totalTradingVolume: '0' }, + }; + } + + // Fetch keys in deployment order (oldest first: createdAt asc, id asc) const profiles = await prisma.creatorProfile.findMany({ where: { - userId: profile.userId, + ...(userId ? { userId } : { id: { in: profileIds } }), ...(cursor ? { OR: [ - { createdAt: { lt: new Date(cursor.createdAt) } }, + { createdAt: { gt: new Date(cursor.createdAt) } }, { createdAt: new Date(cursor.createdAt), - id: { lt: cursor.id }, + id: { gt: cursor.id }, }, ], } : {}), }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], take: requestedLimit + 1, select: { id: true, @@ -269,6 +285,8 @@ export async function getCreatorIssuedKeys( bio: true, avatarUrl: true, isVerified: true, + tradingPaused: true, + deprecatedAt: true, createdAt: true, }, }); @@ -276,11 +294,39 @@ export async function getCreatorIssuedKeys( const hasMore = profiles.length > requestedLimit; const pageProfiles = hasMore ? profiles.slice(0, requestedLimit) : profiles; - // 5. Compute per-key stats in parallel - const items: CreatorIssuedKey[] = await Promise.all( + const items: CreatorDeployedKeyItem[] = await Promise.all( pageProfiles.map(async p => { - const { holderCount, totalTradingVolume } = - await computeCreatorKeyStats(p.id); + const [holderCount, trades, priceSnapshot] = await Promise.all([ + prisma.keyOwnership.count({ + where: { creatorId: p.id, balance: { gt: 0 } }, + }), + prisma.trade.findMany({ + where: { creatorId: p.id }, + select: { price: true, quantity: true }, + }), + prisma.creatorPriceSnapshot.findUnique({ + where: { creatorId: p.id }, + select: { currentPrice: true }, + }), + ]); + + let totalTradingVolume = 0n; + for (const trade of trades) { + try { + totalTradingVolume += + BigInt(trade.price) * BigInt(Math.trunc(Number(trade.quantity))); + } catch { + // skip + } + } + + const status = p.deprecatedAt + ? 'DEPRECATED' + : p.tradingPaused + ? 'PAUSED' + : 'ACTIVE'; + const price = priceSnapshot ? priceSnapshot.currentPrice.toString() : '0'; + return { id: p.id, handle: p.handle, @@ -290,14 +336,15 @@ export async function getCreatorIssuedKeys( isVerified: p.isVerified, holderCount, totalTradingVolume: totalTradingVolume.toString(), + status, + price, createdAt: p.createdAt.toISOString(), }; }) ); - // 6. Compute aggregate stats across ALL keys for this user (not just this page) const allProfiles = await prisma.creatorProfile.findMany({ - where: { userId: profile.userId }, + where: userId ? { userId } : { id: { in: profileIds } }, select: { id: true }, }); const allIds = allProfiles.map(p => p.id); @@ -321,7 +368,7 @@ export async function getCreatorIssuedKeys( totalTradingVolume += BigInt(trade.price) * BigInt(Math.trunc(Number(trade.quantity))); } catch { - // Skip malformed rows + // skip } } }