diff --git a/prisma/schema/migrations/20260825000000_add_wallet_creator_follows/migration.sql b/prisma/schema/migrations/20260825000000_add_wallet_creator_follows/migration.sql new file mode 100644 index 0000000..7d23574 --- /dev/null +++ b/prisma/schema/migrations/20260825000000_add_wallet_creator_follows/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "wallet_creator_follows" ( + "id" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "wallet_creator_follows_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "wallet_creator_follows_walletAddress_creatorId_key" ON "wallet_creator_follows"("walletAddress", "creatorId"); + +-- CreateIndex +CREATE INDEX "wallet_creator_follows_walletAddress_idx" ON "wallet_creator_follows"("walletAddress"); + +-- CreateIndex +CREATE INDEX "wallet_creator_follows_creatorId_idx" ON "wallet_creator_follows"("creatorId"); diff --git a/src/modules/wallets/wallet-following.controllers.ts b/src/modules/wallets/wallet-following.controllers.ts new file mode 100644 index 0000000..416e08c --- /dev/null +++ b/src/modules/wallets/wallet-following.controllers.ts @@ -0,0 +1,34 @@ +// src/modules/wallets/wallet-following.controllers.ts +import { Request, Response, NextFunction } from 'express'; +import { WalletFollowingParamsSchema } from './wallet-following.schemas'; +import { fetchWalletFollowing } from './wallet-following.service'; +import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; + +export async function httpGetWalletFollowing( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = WalletFollowingParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + sendValidationError( + res, + 'Invalid wallet address', + parsedParams.error.issues.map( + (issue: { path: (string | number)[]; message: string }) => ({ + field: 'address', + message: issue.message, + }) + ) + ); + return; + } + + const creators = await fetchWalletFollowing(parsedParams.data.address); + + sendSuccess(res, creators); + } catch (error) { + next(error); + } +} diff --git a/src/modules/wallets/wallet-following.integration.test.ts b/src/modules/wallets/wallet-following.integration.test.ts new file mode 100644 index 0000000..3a2d2ba --- /dev/null +++ b/src/modules/wallets/wallet-following.integration.test.ts @@ -0,0 +1,222 @@ +// Integration test: GET /api/v1/wallets/:address/following +// +// Verifies that the following list endpoint returns all creators a wallet +// follows, ordered alphabetically by display name. Also verifies that an +// empty array is returned for a wallet with no follows and that unauthenticated +// requests receive 401. + +import supertest from 'supertest'; +import { Keypair } from '@stellar/stellar-base'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; +import { signJwt } from '../../middlewares/jwt.middleware'; + +describe('GET /api/v1/wallets/:address/following', () => { + const PREFIX = 'wallet-following-test'; + const walletA = Keypair.random(); + const walletB = Keypair.random(); // wallet with no follows + + const userIdA = `${PREFIX}-user-a`; + const userIdB = `${PREFIX}-user-b`; + const userIdC = `${PREFIX}-user-c`; + const userIdWalletA = `${PREFIX}-user-wallet-a`; + const userIdWalletB = `${PREFIX}-user-wallet-b`; + + const creatorAId = `${PREFIX}-creator-a`; // 'Alice' + const creatorBId = `${PREFIX}-creator-b`; // 'Mike' + const creatorCId = `${PREFIX}-creator-c`; // 'Zara' + + beforeAll(async () => { + // Seed users + await prisma.user.createMany({ + data: [ + { + id: userIdA, + email: `${userIdA}@example.test`, + passwordHash: 'hash', + firstName: 'Follow', + lastName: 'Test A', + }, + { + id: userIdB, + email: `${userIdB}@example.test`, + passwordHash: 'hash', + firstName: 'Follow', + lastName: 'Test B', + }, + { + id: userIdC, + email: `${userIdC}@example.test`, + passwordHash: 'hash', + firstName: 'Follow', + lastName: 'Test C', + }, + { + id: userIdWalletA, + email: `${userIdWalletA}@example.test`, + passwordHash: 'hash', + firstName: 'Wallet', + lastName: 'A', + }, + { + id: userIdWalletB, + email: `${userIdWalletB}@example.test`, + passwordHash: 'hash', + firstName: 'Wallet', + lastName: 'B', + }, + ], + skipDuplicates: true, + }); + + // Seed wallets + await prisma.stellarWallet.createMany({ + data: [ + { userId: userIdWalletA, address: walletA.publicKey() }, + { userId: userIdWalletB, address: walletB.publicKey() }, + ], + skipDuplicates: true, + }); + + // Seed creator profiles (in reverse alphabetical order to test sorting) + await prisma.creatorProfile.createMany({ + data: [ + { + id: creatorCId, + userId: userIdC, + handle: `${PREFIX}-handle-c`, + displayName: 'Zara', + }, + { + id: creatorAId, + userId: userIdA, + handle: `${PREFIX}-handle-a`, + displayName: 'Alice', + }, + { + id: creatorBId, + userId: userIdB, + handle: `${PREFIX}-handle-b`, + displayName: 'Mike', + }, + ], + skipDuplicates: true, + }); + + // Seed follows: wallet A follows all three creators + await prisma.walletCreatorFollow.createMany({ + data: [ + { + walletAddress: walletA.publicKey(), + creatorId: creatorCId, + }, + { + walletAddress: walletA.publicKey(), + creatorId: creatorAId, + }, + { + walletAddress: walletA.publicKey(), + creatorId: creatorBId, + }, + ], + skipDuplicates: true, + }); + }); + + afterAll(async () => { + // Cleanup in reverse dependency order + await prisma.walletCreatorFollow.deleteMany({ + where: { + walletAddress: { in: [walletA.publicKey(), walletB.publicKey()] }, + }, + }); + await prisma.creatorProfile.deleteMany({ + where: { id: { in: [creatorAId, creatorBId, creatorCId] } }, + }); + await prisma.stellarWallet.deleteMany({ + where: { + userId: { in: [userIdWalletA, userIdWalletB] }, + }, + }); + await prisma.user.deleteMany({ + where: { + id: { + in: [ + userIdA, + userIdB, + userIdC, + userIdWalletA, + userIdWalletB, + ], + }, + }, + }); + }); + + // ── Authentication ─────────────────────────────────────────────────────── + + it('returns 401 for an unauthenticated request', async () => { + const res = await supertest(app).get( + `/api/v1/wallets/${walletA.publicKey()}/following` + ); + + expect(res.status).toBe(401); + }); + + // ── Alphabetical ordering ──────────────────────────────────────────────── + + it('returns creators in alphabetical order by display name', async () => { + const token = signJwt({ + walletAddress: walletA.publicKey(), + sub: userIdWalletA, + }); + + const res = await supertest(app) + .get(`/api/v1/wallets/${walletA.publicKey()}/following`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const displayNames = res.body.data.map((c: any) => c.displayName); + expect(displayNames).toEqual(['Alice', 'Mike', 'Zara']); + }); + + // ── Completeness ───────────────────────────────────────────────────────── + + it('returns all followed creators', async () => { + const token = signJwt({ + walletAddress: walletA.publicKey(), + sub: userIdWalletA, + }); + + const res = await supertest(app) + .get(`/api/v1/wallets/${walletA.publicKey()}/following`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(3); + + const ids = res.body.data.map((c: any) => c.id).sort(); + expect(ids).toEqual( + [creatorAId, creatorBId, creatorCId].sort() + ); + }); + + // ── Empty array for wallet with no follows ─────────────────────────────── + + it('returns an empty array for a wallet that follows no one', async () => { + const token = signJwt({ + walletAddress: walletB.publicKey(), + sub: userIdWalletB, + }); + + const res = await supertest(app) + .get(`/api/v1/wallets/${walletB.publicKey()}/following`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual([]); + }); +}); diff --git a/src/modules/wallets/wallet-following.schemas.ts b/src/modules/wallets/wallet-following.schemas.ts new file mode 100644 index 0000000..1b12868 --- /dev/null +++ b/src/modules/wallets/wallet-following.schemas.ts @@ -0,0 +1,7 @@ +// src/modules/wallets/wallet-following.schemas.ts +import { z } from 'zod'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; + +export const WalletFollowingParamsSchema = z.object({ + address: StellarAddressSchema, +}); diff --git a/src/modules/wallets/wallet-following.service.ts b/src/modules/wallets/wallet-following.service.ts new file mode 100644 index 0000000..70adccf --- /dev/null +++ b/src/modules/wallets/wallet-following.service.ts @@ -0,0 +1,42 @@ +// src/modules/wallets/wallet-following.service.ts +import { prisma } from '../../utils/prisma.utils'; + +/** + * Returns all creators that the given wallet follows, ordered + * alphabetically by display name. + */ +export async function fetchWalletFollowing(walletAddress: string) { + const follows = await prisma.walletCreatorFollow.findMany({ + where: { walletAddress }, + select: { + creatorId: true, + createdAt: true, + }, + orderBy: { createdAt: 'asc' }, + }); + + if (follows.length === 0) { + return []; + } + + const creatorIds = follows.map((f) => f.creatorId); + + const creators = await prisma.creatorProfile.findMany({ + where: { id: { in: creatorIds } }, + select: { + id: true, + handle: true, + displayName: true, + avatarUrl: true, + }, + }); + + // Sort by displayName alphabetically (case-insensitive). + creators.sort((a, b) => + a.displayName.localeCompare(b.displayName, undefined, { + sensitivity: 'base', + }) + ); + + return creators; +} diff --git a/src/modules/wallets/wallets.routes.ts b/src/modules/wallets/wallets.routes.ts index 2742213..b9d4231 100644 --- a/src/modules/wallets/wallets.routes.ts +++ b/src/modules/wallets/wallets.routes.ts @@ -28,4 +28,12 @@ walletsRouter.get( */ walletsRouter.get("/:address/holdings", httpGetWalletHoldings); +/** + * GET /api/v1/wallets/:address/following + * + * Returns all creators that the given wallet follows, ordered + * alphabetically by display name. Requires JWT authentication. + */ +walletsRouter.get('/:address/following', jwtAuth, httpGetWalletFollowing); + export default walletsRouter;