diff --git a/docs/feed.md b/docs/feed.md new file mode 100644 index 0000000..69447b6 --- /dev/null +++ b/docs/feed.md @@ -0,0 +1,205 @@ +# Fan Subscription Feed + +`GET /api/v1/feed/subscriptions` — the authenticated fan's aggregated +timeline: posts from every creator they are actively subscribed to, newest +first. + +## Auth + +Requires a valid access token (`JwtAuthGuard`). No optional-auth variant — +unlike `GET /creators/:handle/posts`, there is no anonymous "public feed"; +the feed is always scoped to one fan's own subscriptions. + +- No/invalid token -> `401`. + +## Query parameters + +| Param | Type | Default | Notes | +| -------- | ------ | ------- | ----------------------------------------------- | +| `cursor` | string | - | Opaque, see below. Omit for the first page. | +| `limit` | int | `20` | `1`-`50`. Values above `50` are rejected by the DTO (`VALIDATION_ERROR`, not silently clamped). | +| `filter` | enum | `all` | `all` \| `media` \| `text` | + +`filter=media` returns only posts with `mediaUrl` set; `filter=text` returns +only posts with `mediaUrl` null; `all` returns both. + +## Response envelope + +```json +{ + "data": [ { "id": 42, "creatorId": 7, "title": "...", "...": "..." } ], + "meta": { "nextCursor": "eyJwdWJsaXNoZWRBdCI6Ii4uLiIsImlkIjo0Mn0=", "hasMore": true } +} +``` + +The service/controller layer returns `{ data, nextCursor, hasMore }` +directly; the global `TransformInterceptor` (applied to every endpoint in +this API, see `docs/api-versioning.md`) hoists everything alongside `data` +into `meta`, the same way it already does for `PaginatedResponseDto`'s +`pagination` key elsewhere in the codebase. This is the existing +convention, not something feed-specific. + +To fetch the next page, pass the previous response's `meta.nextCursor` back +as `?cursor=...`. When `meta.hasMore` is `false`, `nextCursor` is `null` and +there is nothing more to fetch. + +## Cursor format + +Base64 of `{"publishedAt": "", "id": }` — the +`(publishedAt, id)` of the last row on the current page. It is opaque by +contract: treat it as an unparseable token, not an offset. A cursor that +fails to base64-decode, isn't valid JSON, or is missing/mistyped either +field is rejected as `400` with `code: VALIDATION_ERROR` (same shape the +global `ValidationPipe` uses for DTO validation failures) rather than +silently falling back to page one. + +## Ordering & stability + +Rows are ordered `publishedAt DESC, id DESC`. `id` is the tiebreaker so +posts sharing an identical `publishedAt` (e.g. seeded/backfilled in bulk) +still get a total order — no duplicates or gaps across pages even when two +posts have the exact same timestamp. + +Keyset (not offset) pagination: page 2 asks for rows strictly less than the +last row's `(publishedAt, id)`, so posts inserted after the fan started +paging never shift already-seen rows onto a later page or vice versa. + +## Visibility + +A post appears in the feed if and only if: + +1. the fan has an **active** subscription to that post's creator + (`subscriptions.status = 'active'` — a cancelled row, regardless of when + it was cancelled, excludes that creator's posts immediately; see + `docs/post-visibility.md` for why there's no grace period in this + schema), and +2. the post is not soft-deleted (`deletedAt IS NULL`) and is published + (`publishedAt IS NOT NULL`). + +Both `public` and `subscribers`-visibility posts from an actively-subscribed +creator appear — being subscribed already unlocks both. Posts from a +creator the fan is *not* subscribed to never appear in this feed, including +that creator's `public` posts: this endpoint is "posts from people I +follow", not a global public timeline. (`public` posts from a non-subscribed +creator are still reachable via `GET /creators/:handle/posts`.) + +## Empty states + +| Situation | Response | +| -------------------------------------------- | ------------------------------------------- | +| Fan has zero subscriptions | `data: []`, `nextCursor: null`, `hasMore: false` | +| Fan has subscriptions but none have posted | same as above | +| Fan cancels their only subscription | that creator's posts disappear from the very next request (no cache/delay) | + +## Query design + +```sql +SELECT post.* +FROM posts post +INNER JOIN subscriptions sub + ON sub."creatorId" = post."creatorId" + AND sub."fanId" = :fanId + AND sub."status" = 'active' +WHERE post."deletedAt" IS NULL + AND post."publishedAt" IS NOT NULL + -- + optional filter, + optional cursor predicate +ORDER BY post."publishedAt" DESC, post.id DESC +LIMIT :limit + 1; +``` + +(`LIMIT :limit + 1` — the service fetches one extra row to derive `hasMore` +without a second `COUNT` query, then trims it off before returning.) + +### Why a join, not `WHERE creatorId IN (:...allCreatorIds)` + +An `IN` list requires first loading every subscribed creator id for the fan, +then inlining a list that grows linearly with how many creators the fan +follows — with enough subscriptions this becomes a large, non-reusable +query string and defeats plan caching. The join instead lets Postgres do the +subscription lookup and the post lookup as two indexed steps within a single +plan: + +- `subscriptions` has an existing `(fanId, status)` index — narrows to this + fan's active creator ids without a table scan. +- `posts` has an existing `(creatorId, deletedAt, publishedAt)` index, plus + a feed-specific `(creatorId, deletedAt, publishedAt DESC, id DESC)` index + added in migration `1785200000000-AddFeedIndexes` — for each active + creator id, its non-deleted published posts are already in the exact + order the feed needs, so only the cross-creator merge needs a sort, not a + full-table one. + +This scales with "how many active subscriptions does this fan have" the +same way regardless of whether that number is 5 or 500 — it's still index +lookups per creator plus a bounded (`LIMIT`) merge, never a full scan of +`posts`. + +### EXPLAIN + +Representative plan shape for a fan with ~25 active subscriptions (seed via +`src/seeds/seed.ts` or the feed e2e spec's `seedManyCreators` helper, then +run this against a local Postgres — there's no live DB in this review +environment, so this is the plan the index/join shape above is designed to +produce, described so a reviewer can reproduce and confirm it): + +```sql +EXPLAIN ANALYZE +SELECT post.* +FROM posts post +INNER JOIN subscriptions sub + ON sub."creatorId" = post."creatorId" + AND sub."fanId" = 123 + AND sub."status" = 'active' +WHERE post."deletedAt" IS NULL + AND post."publishedAt" IS NOT NULL +ORDER BY post."publishedAt" DESC, post.id DESC +LIMIT 21; +``` + +Expected shape: + +``` +Limit + -> Sort (key: post."publishedAt" DESC, post.id DESC) + -> Nested Loop + -> Index Scan using IDX_subscriptions_fanId_status on subscriptions sub + Index Cond: (fanId = 123 AND status = 'active') + -> Index Scan using IDX_posts_feed_creator_published_id on posts post + Index Cond: (creatorId = sub."creatorId" AND deletedAt IS NULL) + Filter: publishedAt IS NOT NULL +``` + +i.e. an indexed lookup per subscribed creator, not a sequential scan of +`posts`. The `Sort` node is unavoidable when merging multiple creators' +already-sorted streams into one global order, but it only ever sorts the +rows actually fetched from each creator branch, not the whole table. + +### Scale path beyond this + +At subscription counts where even indexed per-creator lookups become the +bottleneck (thousands of active subscriptions for one fan, or very +high-cardinality creators), the standard next step is fan-out-on-write: a +`feed_entries (fanId, postId, publishedAt)` table populated when a post is +published, read with a single `(fanId, publishedAt DESC)` index and no join +at all. Out of scope here — not needed at the "≥25 creators" bar this task +targets — but noted so the join-based design isn't mistaken for the final +word if usage grows well past that. + +## Caching + +No HTTP caching (`Cache-Control` / `ETag`) is applied. The feed is +per-fan and auth-gated (`Authorization: Bearer `), so a shared/proxy +cache keyed on the URL alone would either leak one fan's feed to another +fan reusing a cache key, or never hit because the auth header varies by +user — neither is useful here. An `ETag` on the first page is also awkward +for keyset pagination: the "current" first page changes the instant any +subscribed creator posts, so an ETag would need revalidation on nearly every +request anyway, i.e. no material win over just not caching. + +## Errors + +| Condition | Status | Body | +| ------------------------ | :----: | -------------------------------------------------- | +| No/invalid auth | 401 | standard auth error envelope | +| Malformed `cursor` | 400 | `{ "code": "VALIDATION_ERROR", "message": "Invalid cursor" }` | +| `limit` out of `1`-`50` | 400 | `{ "code": "VALIDATION_ERROR", ... }` (global `ValidationPipe`) | +| Invalid `filter` value | 400 | `{ "code": "VALIDATION_ERROR", ... }` (global `ValidationPipe`) | diff --git a/src/app.module.ts b/src/app.module.ts index ece5889..62916fa 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { AuthModule } from './auth/auth.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { CreatorsModule } from './creators/creators.module'; import { PostsModule } from './posts/posts.module'; +import { FeedModule } from './feed/feed.module'; import { NotificationsModule } from './notifications/notifications.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -46,6 +47,7 @@ import { TipsModule } from './tips/tips.module'; SubscriptionsModule, CreatorsModule, PostsModule, + FeedModule, NotificationsModule, AuditModule, AdminModule, diff --git a/src/audit/audit-action.enum.ts b/src/audit/audit-action.enum.ts index aae51ea..45d27c2 100644 --- a/src/audit/audit-action.enum.ts +++ b/src/audit/audit-action.enum.ts @@ -1,6 +1,7 @@ export enum AuditAction { USER_ROLE_CHANGED = 'USER_ROLE_CHANGED', USER_DELETED = 'USER_DELETED', + USER_SELF_DELETED = 'USER_SELF_DELETED', USER_LOGIN_FAILED = 'USER_LOGIN_FAILED', USER_SELF_DELETED = 'USER_SELF_DELETED', GDPR_DATA_EXPORTED = 'GDPR_DATA_EXPORTED', diff --git a/src/feed/dtos/feed-query.dto.ts b/src/feed/dtos/feed-query.dto.ts new file mode 100644 index 0000000..ca78618 --- /dev/null +++ b/src/feed/dtos/feed-query.dto.ts @@ -0,0 +1,44 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export type FeedFilter = 'all' | 'media' | 'text'; + +export const FEED_DEFAULT_LIMIT = 20; +export const FEED_MAX_LIMIT = 50; + +export class FeedQueryDto { + @ApiPropertyOptional({ + description: + "Opaque cursor from a previous page's `nextCursor` (base64 of {publishedAt, id}). Omit for the first page.", + example: + 'eyJwdWJsaXNoZWRBdCI6IjIwMjYtMDctMjlUMTA6MDA6MDAuMDAwWiIsImlkIjo0Mn0=', + }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ + description: `Page size (1-${FEED_MAX_LIMIT})`, + example: FEED_DEFAULT_LIMIT, + default: FEED_DEFAULT_LIMIT, + minimum: 1, + maximum: FEED_MAX_LIMIT, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(FEED_MAX_LIMIT) + limit?: number = FEED_DEFAULT_LIMIT; + + @ApiPropertyOptional({ + description: + 'Restrict the feed to posts with media, text-only posts, or all posts', + enum: ['all', 'media', 'text'], + default: 'all', + }) + @IsOptional() + @IsIn(['all', 'media', 'text']) + filter?: FeedFilter = 'all'; +} diff --git a/src/feed/dtos/feed-response.dto.ts b/src/feed/dtos/feed-response.dto.ts new file mode 100644 index 0000000..81a7087 --- /dev/null +++ b/src/feed/dtos/feed-response.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PostResponseDto } from '../../posts/dtos/post-response.dto'; + +export class FeedResponseDto { + @ApiProperty({ type: [PostResponseDto], description: 'Page of feed posts' }) + data: PostResponseDto[]; + + @ApiPropertyOptional({ + example: + 'eyJwdWJsaXNoZWRBdCI6IjIwMjYtMDctMjlUMTA6MDA6MDAuMDAwWiIsImlkIjo0Mn0=', + description: + 'Opaque cursor to pass as `cursor` for the next page, or null if this is the last page', + nullable: true, + }) + nextCursor: string | null; + + @ApiProperty({ + example: true, + description: 'Whether more posts exist after this page', + }) + hasMore: boolean; +} diff --git a/src/feed/feed-cursor.util.spec.ts b/src/feed/feed-cursor.util.spec.ts new file mode 100644 index 0000000..6eba4eb --- /dev/null +++ b/src/feed/feed-cursor.util.spec.ts @@ -0,0 +1,58 @@ +import { BadRequestException } from '@nestjs/common'; +import { decodeFeedCursor, encodeFeedCursor } from './feed-cursor.util'; + +describe('feed cursor', () => { + it('round-trips publishedAt and id through encode/decode', () => { + const cursor = { publishedAt: '2026-07-29T10:00:00.000Z', id: 42 }; + const decoded = decodeFeedCursor(encodeFeedCursor(cursor)); + expect(decoded).toEqual(cursor); + }); + + it('produces an opaque base64 string, not raw JSON', () => { + const encoded = encodeFeedCursor({ + publishedAt: '2026-07-29T10:00:00.000Z', + id: 1, + }); + expect(() => JSON.parse(encoded)).toThrow(); + expect(Buffer.from(encoded, 'base64').toString('utf8')).toContain( + 'publishedAt', + ); + }); + + it.each([ + ['not base64 json at all', 'not-a-cursor'], + ['valid base64 but not JSON', Buffer.from('not json').toString('base64')], + [ + 'JSON missing id', + Buffer.from( + JSON.stringify({ publishedAt: '2026-01-01T00:00:00.000Z' }), + ).toString('base64'), + ], + [ + 'JSON with non-numeric id', + Buffer.from( + JSON.stringify({ publishedAt: '2026-01-01T00:00:00.000Z', id: '42' }), + ).toString('base64'), + ], + [ + 'JSON with unparseable publishedAt', + Buffer.from( + JSON.stringify({ publishedAt: 'not-a-date', id: 42 }), + ).toString('base64'), + ], + [ + 'a JSON array instead of an object', + Buffer.from('[1,2,3]').toString('base64'), + ], + ])('rejects %s as VALIDATION_ERROR', (_label, raw) => { + expect(() => decodeFeedCursor(raw)).toThrow(BadRequestException); + try { + decodeFeedCursor(raw); + fail('expected decodeFeedCursor to throw'); + } catch (err) { + expect((err as BadRequestException).getResponse()).toMatchObject({ + code: 'VALIDATION_ERROR', + }); + } + }); +}); diff --git a/src/feed/feed-cursor.util.ts b/src/feed/feed-cursor.util.ts new file mode 100644 index 0000000..8ff74ba --- /dev/null +++ b/src/feed/feed-cursor.util.ts @@ -0,0 +1,49 @@ +import { BadRequestException } from '@nestjs/common'; + +export interface FeedCursor { + publishedAt: string; + id: number; +} + +const invalidCursor = () => + new BadRequestException({ + message: 'Invalid cursor', + code: 'VALIDATION_ERROR', + }); + +export function encodeFeedCursor(cursor: FeedCursor): string { + return Buffer.from(JSON.stringify(cursor)).toString('base64'); +} + +/** + * Cursor is opaque to callers by design (see docs/feed.md) so a caller can + * only ever supply back a value we previously issued. Any deviation + * (re-encoded JSON, tampered fields, garbage) is rejected as 400 + * VALIDATION_ERROR rather than silently coerced, since a malformed cursor + * silently accepted would produce an incorrect/unstable page rather than a + * clear error. + */ +export function decodeFeedCursor(raw: string): FeedCursor { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')); + } catch { + throw invalidCursor(); + } + + if (parsed === null || typeof parsed !== 'object') { + throw invalidCursor(); + } + + const { publishedAt, id } = parsed as Record; + if ( + typeof publishedAt !== 'string' || + Number.isNaN(new Date(publishedAt).getTime()) || + typeof id !== 'number' || + !Number.isFinite(id) + ) { + throw invalidCursor(); + } + + return { publishedAt, id }; +} diff --git a/src/feed/feed.controller.ts b/src/feed/feed.controller.ts new file mode 100644 index 0000000..3e8a431 --- /dev/null +++ b/src/feed/feed.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { Request } from 'express'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { FeedService } from './feed.service'; +import { FeedQueryDto } from './dtos/feed-query.dto'; +import { FeedResponseDto } from './dtos/feed-response.dto'; + +interface AuthenticatedRequest extends Request { + user: { userId: number; email: string; username: string }; +} + +@ApiTags('Feed') +@Controller('api/v1/feed') +export class FeedController { + constructor(private readonly feedService: FeedService) {} + + @Get('subscriptions') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: + "Cursor-paginated feed of posts from the authenticated fan's active " + + 'subscriptions, newest first. See docs/feed.md for cursor format and ' + + 'visibility rules.', + }) + @ApiResponse({ + status: 200, + description: 'Feed page retrieved', + type: FeedResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ + status: 400, + description: 'Malformed cursor (code: VALIDATION_ERROR)', + }) + @ApiQuery({ name: 'cursor', required: false }) + @ApiQuery({ name: 'limit', required: false, example: 20 }) + @ApiQuery({ name: 'filter', required: false, enum: ['all', 'media', 'text'] }) + async getSubscriptionFeed( + @Query() query: FeedQueryDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.feedService.getSubscriptionFeed(req.user.userId, query); + } +} diff --git a/src/feed/feed.module.ts b/src/feed/feed.module.ts new file mode 100644 index 0000000..9abf95a --- /dev/null +++ b/src/feed/feed.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Post } from '../posts/post.entity'; +import { FeedController } from './feed.controller'; +import { FeedService } from './feed.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Post])], + controllers: [FeedController], + providers: [FeedService], +}) +export class FeedModule {} diff --git a/src/feed/feed.service.spec.ts b/src/feed/feed.service.spec.ts new file mode 100644 index 0000000..4cb18f7 --- /dev/null +++ b/src/feed/feed.service.spec.ts @@ -0,0 +1,177 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { FeedService } from './feed.service'; +import { Post } from '../posts/post.entity'; +import { encodeFeedCursor } from './feed-cursor.util'; + +describe('FeedService', () => { + let service: FeedService; + let mockPostsRepo: any; + + const mockPost = (overrides: Partial = {}): Post => + ({ + id: 1, + creatorId: 7, + title: 'Post', + body: 'Body', + mediaUrl: null, + visibility: 'public', + publishedAt: new Date('2026-07-29T10:00:00.000Z'), + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }) as Post; + + function mockQueryBuilder(rows: Post[]) { + const qb = { + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(rows), + }; + mockPostsRepo.createQueryBuilder.mockReturnValue(qb); + return qb; + } + + beforeEach(async () => { + mockPostsRepo = { createQueryBuilder: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeedService, + { provide: getRepositoryToken(Post), useValue: mockPostsRepo }, + ], + }).compile(); + + service = module.get(FeedService); + }); + + it("scopes the query to the fan's active subscriptions via an INNER JOIN, not an IN-list", async () => { + const qb = mockQueryBuilder([mockPost()]); + + await service.getSubscriptionFeed(99, { filter: 'all' } as any); + + expect(qb.innerJoin).toHaveBeenCalledWith( + 'subscriptions', + 'sub', + expect.stringContaining('sub."fanId" = :fanId'), + { fanId: 99, status: 'active' }, + ); + }); + + it('excludes soft-deleted and unpublished posts', async () => { + const qb = mockQueryBuilder([mockPost()]); + + await service.getSubscriptionFeed(1, { filter: 'all' } as any); + + expect(qb.where).toHaveBeenCalledWith('post."deletedAt" IS NULL'); + expect(qb.andWhere).toHaveBeenCalledWith('post."publishedAt" IS NOT NULL'); + }); + + it('orders by publishedAt DESC then id DESC for stable pagination', async () => { + const qb = mockQueryBuilder([mockPost()]); + + await service.getSubscriptionFeed(1, { filter: 'all' } as any); + + expect(qb.orderBy).toHaveBeenCalledWith('post.publishedAt', 'DESC'); + expect(qb.addOrderBy).toHaveBeenCalledWith('post.id', 'DESC'); + }); + + it('applies a media-only filter when filter=media', async () => { + const qb = mockQueryBuilder([]); + + await service.getSubscriptionFeed(1, { filter: 'media' } as any); + + expect(qb.andWhere).toHaveBeenCalledWith('post."mediaUrl" IS NOT NULL'); + }); + + it('applies a text-only filter when filter=text', async () => { + const qb = mockQueryBuilder([]); + + await service.getSubscriptionFeed(1, { filter: 'text' } as any); + + expect(qb.andWhere).toHaveBeenCalledWith('post."mediaUrl" IS NULL'); + }); + + it('decodes a valid cursor into a keyset predicate', async () => { + const qb = mockQueryBuilder([]); + const cursor = encodeFeedCursor({ + publishedAt: '2026-07-29T09:00:00.000Z', + id: 5, + }); + + await service.getSubscriptionFeed(1, { filter: 'all', cursor } as any); + + expect(qb.andWhere).toHaveBeenCalledWith( + expect.stringContaining('post."publishedAt" < :cursorPublishedAt'), + { + cursorPublishedAt: new Date('2026-07-29T09:00:00.000Z'), + cursorId: 5, + }, + ); + }); + + it('rejects a malformed cursor with 400 VALIDATION_ERROR before querying', async () => { + mockQueryBuilder([]); + + await expect( + service.getSubscriptionFeed(1, { + filter: 'all', + cursor: 'garbage', + } as any), + ).rejects.toThrow(BadRequestException); + expect(mockPostsRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('signals hasMore and derives nextCursor from the last row when an extra row is fetched', async () => { + const limit = 2; + const rows = [ + mockPost({ id: 3, publishedAt: new Date('2026-07-29T12:00:00.000Z') }), + mockPost({ id: 2, publishedAt: new Date('2026-07-29T11:00:00.000Z') }), + mockPost({ id: 1, publishedAt: new Date('2026-07-29T10:00:00.000Z') }), // extra row beyond limit + ]; + const qb = mockQueryBuilder(rows); + + const result = await service.getSubscriptionFeed(1, { + filter: 'all', + limit, + } as any); + + expect(qb.take).toHaveBeenCalledWith(limit + 1); + expect(result.data).toHaveLength(limit); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe( + encodeFeedCursor({ publishedAt: '2026-07-29T11:00:00.000Z', id: 2 }), + ); + }); + + it('reports hasMore false and a null nextCursor on the last page', async () => { + const rows = [mockPost({ id: 1 })]; + mockQueryBuilder(rows); + + const result = await service.getSubscriptionFeed(1, { + filter: 'all', + limit: 20, + } as any); + + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('returns an empty page (not an error) when there are no matching posts', async () => { + mockQueryBuilder([]); + + const result = await service.getSubscriptionFeed(1, { + filter: 'all', + } as any); + + expect(result.data).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); +}); diff --git a/src/feed/feed.service.ts b/src/feed/feed.service.ts new file mode 100644 index 0000000..a4160ae --- /dev/null +++ b/src/feed/feed.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Post } from '../posts/post.entity'; +import { PostResponseDto } from '../posts/dtos/post-response.dto'; +import { FeedQueryDto, FEED_DEFAULT_LIMIT } from './dtos/feed-query.dto'; +import { FeedResponseDto } from './dtos/feed-response.dto'; +import { decodeFeedCursor, encodeFeedCursor } from './feed-cursor.util'; + +@Injectable() +export class FeedService { + constructor( + @InjectRepository(Post) private readonly postsRepo: Repository, + ) {} + + /** + * Aggregated feed of posts from every creator the fan is actively + * subscribed to. Visibility is implicit in the join: an INNER JOIN against + * `subscriptions` scoped to this fan + status='active' both selects "only + * active subscriptions" and "posts from those creators" in one pass, so + * public and subscriber-only posts from a subscribed creator both appear, + * and nothing from a non-subscribed creator ever can (see docs/feed.md). + * + * Deliberately NOT `WHERE post.creatorId IN (:...creatorIds)` — that + * requires a separate round trip to list every subscribed creator id and + * inlines a list that grows unboundedly with the fan's subscription count. + * The join lets Postgres use the existing `(fanId, status)` index on + * subscriptions and the existing `(creatorId, deletedAt, publishedAt)` + * index on posts directly, regardless of how many creators the fan + * follows (see docs/feed.md "Query plan" for EXPLAIN notes). + */ + async getSubscriptionFeed( + fanId: number, + query: FeedQueryDto, + ): Promise { + const limit = Math.min(query.limit ?? FEED_DEFAULT_LIMIT, 50); + const cursor = query.cursor ? decodeFeedCursor(query.cursor) : null; + + const qb = this.postsRepo + .createQueryBuilder('post') + .innerJoin( + 'subscriptions', + 'sub', + 'sub."creatorId" = post."creatorId" AND sub."fanId" = :fanId AND sub."status" = :status', + { fanId, status: 'active' }, + ) + .where('post."deletedAt" IS NULL') + .andWhere('post."publishedAt" IS NOT NULL'); + + if (query.filter === 'media') { + qb.andWhere('post."mediaUrl" IS NOT NULL'); + } else if (query.filter === 'text') { + qb.andWhere('post."mediaUrl" IS NULL'); + } + + if (cursor) { + qb.andWhere( + '(post."publishedAt" < :cursorPublishedAt OR (post."publishedAt" = :cursorPublishedAt AND post.id < :cursorId))', + { + cursorPublishedAt: new Date(cursor.publishedAt), + cursorId: cursor.id, + }, + ); + } + + qb.orderBy('post.publishedAt', 'DESC') + .addOrderBy('post.id', 'DESC') + .take(limit + 1); + + const rows = await qb.getMany(); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page[page.length - 1]; + + return { + data: page.map((post) => this.toDto(post)), + nextCursor: + hasMore && last + ? encodeFeedCursor({ + publishedAt: last.publishedAt!.toISOString(), + id: last.id, + }) + : null, + hasMore, + }; + } + + private toDto(post: Post): PostResponseDto { + return { + id: post.id, + creatorId: post.creatorId, + title: post.title, + body: post.body, + mediaUrl: post.mediaUrl, + visibility: post.visibility, + publishedAt: post.publishedAt, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + }; + } +} diff --git a/src/migrations/1785200000000-AddFeedIndexes.ts b/src/migrations/1785200000000-AddFeedIndexes.ts new file mode 100644 index 0000000..7f8ae8f --- /dev/null +++ b/src/migrations/1785200000000-AddFeedIndexes.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Supports GET /api/v1/feed/subscriptions (docs/feed.md). The feed query + * joins subscriptions -> posts per active-subscribed creator and needs a + * stable (publishedAt DESC, id DESC) ordering per creator for the keyset + * cursor. Postgres TableIndex helper only emits ASC columns, so this uses + * raw SQL to get DESC directly in the index and avoid a sort step within + * each creator's row stream. + */ +export class AddFeedIndexes1785200000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX "IDX_posts_feed_creator_published_id" + ON "posts" ("creatorId", "deletedAt", "publishedAt" DESC, "id" DESC) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX "IDX_posts_feed_creator_published_id" + `); + } +} diff --git a/test/feed.e2e-spec.ts b/test/feed.e2e-spec.ts new file mode 100644 index 0000000..f1a3180 --- /dev/null +++ b/test/feed.e2e-spec.ts @@ -0,0 +1,363 @@ +import * as request from 'supertest'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { clearDatabase, createE2eApp, E2eTestApp } from './helpers/e2e-app'; +import { bearerToken, signupUser, AuthResult } from './helpers/auth'; +import { Post } from '../src/posts/post.entity'; +import { Subscription } from '../src/subscriptions/subscription.entity'; +import { User } from '../src/users/user.entity'; + +describe('Fan Subscription Feed (e2e)', () => { + let testApp: E2eTestApp; + let postRepo: Repository; + let subscriptionRepo: Repository; + let userRepo: Repository; + + beforeAll(async () => { + testApp = await createE2eApp(); + postRepo = testApp.moduleFixture.get>( + getRepositoryToken(Post), + ); + subscriptionRepo = testApp.moduleFixture.get>( + getRepositoryToken(Subscription), + ); + userRepo = testApp.moduleFixture.get>( + getRepositoryToken(User), + ); + }); + + beforeEach(async () => { + await clearDatabase(testApp.dataSource); + }); + + afterAll(async () => { + await testApp.app.close(); + }); + + const server = () => testApp.app.getHttpServer(); + + async function makeFan(overrides: Parameters[1] = {}) { + return signupUser(testApp.app, { email: 'fan@example.com', ...overrides }); + } + + /** Bare user row for a "creator" — bypasses signup/bcrypt for fast bulk seeding. */ + async function makeCreatorUser(email: string): Promise { + return userRepo.save( + userRepo.create({ name: email, email, password: 'unused' }), + ); + } + + async function makePost( + creatorId: number, + overrides: Partial = {}, + ): Promise { + return postRepo.save( + postRepo.create({ + creatorId, + title: overrides.title ?? 'Untitled', + body: overrides.body ?? 'Body', + mediaUrl: overrides.mediaUrl ?? null, + visibility: overrides.visibility ?? 'public', + publishedAt: + overrides.publishedAt === undefined + ? new Date() + : overrides.publishedAt, + deletedAt: overrides.deletedAt ?? null, + }), + ); + } + + async function activeSubscribe( + fanId: number, + creatorId: number, + ): Promise { + await subscriptionRepo.save( + subscriptionRepo.create({ fanId, creatorId, status: 'active' }), + ); + } + + async function subscribeViaApi(fan: AuthResult, creatorId: number) { + await request(server()) + .post('/subscriptions') + .set('Authorization', bearerToken(fan.token)) + .send({ creatorId }) + .expect(201); + } + + it('requires authentication (401 with no token)', async () => { + await request(server()).get('/api/v1/feed/subscriptions').expect(401); + }); + + it('returns an empty page with hasMore false when the fan has no subscriptions', async () => { + const fan = await makeFan(); + + const res = await request(server()) + .get('/api/v1/feed/subscriptions') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + expect(res.body.data).toEqual([]); + expect(res.body.hasMore).toBe(false); + expect(res.body.nextCursor).toBeNull(); + }); + + it('shows both public and subscriber-only posts from an actively subscribed creator', async () => { + const fan = await makeFan(); + const creator = await makeCreatorUser('creator1@example.com'); + await activeSubscribe(fan.user.id, creator.id); + + await makePost(creator.id, { title: 'Public post', visibility: 'public' }); + await makePost(creator.id, { + title: 'Subscriber post', + visibility: 'subscribers', + }); + + const res = await request(server()) + .get('/api/v1/feed/subscriptions') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + const titles = res.body.data.map((p: { title: string }) => p.title); + expect(titles).toEqual( + expect.arrayContaining(['Public post', 'Subscriber post']), + ); + }); + + it('never shows posts from a creator the fan is not subscribed to, even if public', async () => { + const fan = await makeFan(); + const subscribed = await makeCreatorUser('subscribed@example.com'); + const stranger = await makeCreatorUser('stranger@example.com'); + await activeSubscribe(fan.user.id, subscribed.id); + + await makePost(subscribed.id, { title: 'From subscribed creator' }); + await makePost(stranger.id, { + title: 'From unrelated creator', + visibility: 'public', + }); + + const res = await request(server()) + .get('/api/v1/feed/subscriptions') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + const titles = res.body.data.map((p: { title: string }) => p.title); + expect(titles).toEqual(['From subscribed creator']); + }); + + it("immediately stops showing a creator's posts once the subscription is cancelled", async () => { + const fan = await makeFan(); + const creator = await makeCreatorUser('cancel-creator@example.com'); + await subscribeViaApi(fan, creator.id); + await makePost(creator.id, { + title: 'Subscriber-only post', + visibility: 'subscribers', + }); + + const before = await request(server()) + .get('/api/v1/feed/subscriptions') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + expect(before.body.data).toHaveLength(1); + + await request(server()) + .delete(`/subscriptions/${creator.id}`) + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + const after = await request(server()) + .get('/api/v1/feed/subscriptions') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + expect(after.body.data).toEqual([]); + }); + + it('excludes soft-deleted posts', async () => { + const fan = await makeFan(); + const creator = await makeCreatorUser('deleted-post-creator@example.com'); + await activeSubscribe(fan.user.id, creator.id); + + await makePost(creator.id, { title: 'Visible post' }); + await makePost(creator.id, { + title: 'Deleted post', + deletedAt: new Date(), + }); + + const res = await request(server()) + .get('/api/v1/feed/subscriptions') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + const titles = res.body.data.map((p: { title: string }) => p.title); + expect(titles).toEqual(['Visible post']); + }); + + it('excludes unpublished (draft) posts', async () => { + const fan = await makeFan(); + const creator = await makeCreatorUser('draft-creator@example.com'); + await activeSubscribe(fan.user.id, creator.id); + + await makePost(creator.id, { title: 'Published post' }); + await makePost(creator.id, { title: 'Draft post', publishedAt: null }); + + const res = await request(server()) + .get('/api/v1/feed/subscriptions') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + const titles = res.body.data.map((p: { title: string }) => p.title); + expect(titles).toEqual(['Published post']); + }); + + it('filters to media-only or text-only posts', async () => { + const fan = await makeFan(); + const creator = await makeCreatorUser('filter-creator@example.com'); + await activeSubscribe(fan.user.id, creator.id); + + await makePost(creator.id, { + title: 'Text post', + mediaUrl: null, + }); + await makePost(creator.id, { + title: 'Media post', + mediaUrl: 'https://cdn.example.com/pic.jpg', + }); + + const mediaRes = await request(server()) + .get('/api/v1/feed/subscriptions?filter=media') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + expect(mediaRes.body.data.map((p: { title: string }) => p.title)).toEqual([ + 'Media post', + ]); + + const textRes = await request(server()) + .get('/api/v1/feed/subscriptions?filter=text') + .set('Authorization', bearerToken(fan.token)) + .expect(200); + expect(textRes.body.data.map((p: { title: string }) => p.title)).toEqual([ + 'Text post', + ]); + }); + + it('returns 400 VALIDATION_ERROR for a malformed cursor', async () => { + const fan = await makeFan(); + + const res = await request(server()) + .get('/api/v1/feed/subscriptions?cursor=not-a-valid-cursor') + .set('Authorization', bearerToken(fan.token)) + .expect(400); + + expect(res.body.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 VALIDATION_ERROR when limit exceeds the hard max', async () => { + const fan = await makeFan(); + + const res = await request(server()) + .get('/api/v1/feed/subscriptions?limit=500') + .set('Authorization', bearerToken(fan.token)) + .expect(400); + + expect(res.body.code).toBe('VALIDATION_ERROR'); + }); + + it('paginates by cursor with no duplicates and no gaps across pages', async () => { + const fan = await makeFan(); + const creator = await makeCreatorUser('paging-creator@example.com'); + await activeSubscribe(fan.user.id, creator.id); + + const base = new Date('2026-01-01T00:00:00.000Z'); + const created: Post[] = []; + for (let i = 0; i < 9; i++) { + created.push( + await makePost(creator.id, { + title: `Post ${i}`, + publishedAt: new Date(base.getTime() - i * 1000), + }), + ); + } + + const seen: number[] = []; + let cursor: string | undefined; + for (let page = 0; page < 5; page++) { + const res = await request(server()) + .get('/api/v1/feed/subscriptions') + .query({ limit: 2, ...(cursor ? { cursor } : {}) }) + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + seen.push(...res.body.data.map((p: { id: number }) => p.id)); + cursor = res.body.nextCursor ?? undefined; + if (!res.body.hasMore) break; + } + + expect(seen).toHaveLength(created.length); + expect(new Set(seen).size).toBe(created.length); + expect(seen).toEqual(created.map((p) => p.id).sort((a, b) => b - a)); + }); + + it('keeps a stable total order when multiple posts share the exact same publishedAt', async () => { + const fan = await makeFan(); + const creator = await makeCreatorUser('tie-creator@example.com'); + await activeSubscribe(fan.user.id, creator.id); + + const tiedAt = new Date('2026-02-01T00:00:00.000Z'); + const tied = [ + await makePost(creator.id, { title: 'Tie A', publishedAt: tiedAt }), + await makePost(creator.id, { title: 'Tie B', publishedAt: tiedAt }), + await makePost(creator.id, { title: 'Tie C', publishedAt: tiedAt }), + ]; + + const seen: number[] = []; + let cursor: string | undefined; + for (let page = 0; page < 5; page++) { + const res = await request(server()) + .get('/api/v1/feed/subscriptions') + .query({ limit: 1, ...(cursor ? { cursor } : {}) }) + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + seen.push(...res.body.data.map((p: { id: number }) => p.id)); + cursor = res.body.nextCursor ?? undefined; + if (!res.body.hasMore) break; + } + + expect(seen).toEqual(tied.map((p) => p.id).sort((a, b) => b - a)); + }); + + it('stays correct when the fan has a large number of active subscriptions (scale path)', async () => { + const fan = await makeFan(); + const creatorCount = 25; + const expectedIds: number[] = []; + + for (let i = 0; i < creatorCount; i++) { + const creator = await makeCreatorUser(`scale-creator-${i}@example.com`); + await activeSubscribe(fan.user.id, creator.id); + const post = await makePost(creator.id, { + title: `Scale post ${i}`, + publishedAt: new Date(Date.now() - i * 1000), + }); + expectedIds.push(post.id); + } + // One unsubscribed creator's post must never appear. + const outsider = await makeCreatorUser('scale-outsider@example.com'); + await makePost(outsider.id, { title: 'Should never appear' }); + + const seen: number[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page++) { + const res = await request(server()) + .get('/api/v1/feed/subscriptions') + .query({ limit: 10, ...(cursor ? { cursor } : {}) }) + .set('Authorization', bearerToken(fan.token)) + .expect(200); + + seen.push(...res.body.data.map((p: { id: number }) => p.id)); + cursor = res.body.nextCursor ?? undefined; + if (!res.body.hasMore) break; + } + + expect(new Set(seen).size).toBe(creatorCount); + expect(new Set(seen)).toEqual(new Set(expectedIds)); + }); +});