diff --git a/docs/creators-api.md b/docs/creators-api.md new file mode 100644 index 0000000..401f678 --- /dev/null +++ b/docs/creators-api.md @@ -0,0 +1,137 @@ +# Creators API + +The Creators module adds a public creator identity on top of the base `User` +record. Every account is a `User`; a `CreatorProfile` is created (1:1) only once +a user **onboards** as a creator. Onboarding upgrades the user's role to +`creator` and persists a public profile used in discovery flows. + +All routes are documented in Swagger under the **Creators** tag at `/api`. + +## Data model + +`CreatorProfile`: + +| Field | Type | Notes | +| ------------- | --------- | ---------------------------------------------- | +| `id` | uuid | Primary key | +| `userId` | int | FK → `users.id`, unique (1:1) | +| `handle` | varchar | Unique, `^[a-z0-9_]{3,30}$` | +| `displayName` | varchar | nullable | +| `bio` | varchar | nullable, max 300 chars | +| `bannerUrl` | varchar | nullable | +| `category` | varchar | nullable | +| `isOnboarded` | boolean | `true` after onboarding | +| `createdAt` | timestamp | Set on creation | +| `updatedAt` | timestamp | Set on update | + +### Public vs owner views + +- `CreatorResponseDto` (public): `handle`, `displayName`, `bio`, `bannerUrl`, + `category`, `createdAt`. Never exposes `userId`, email or password. +- `CreatorPrivateDto` (owner): the public fields plus `id`, `userId`, + `isOnboarded`, `updatedAt`. + +## Endpoints + +| Method | Path | Auth | Description | +| ------ | -------------------- | ------ | ------------------------------------ | +| POST | `/creators/onboard` | Bearer | Authenticated user becomes a creator | +| GET | `/creators/:handle` | Public | Public profile by handle | +| PATCH | `/creators/me` | Bearer | Owner updates their own profile | + +### POST /creators/onboard + +Creates the caller's creator profile and upgrades their role to `creator`. + +```bash +curl -X POST http://localhost:3000/creators/onboard \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "handle": "jane_doe", + "displayName": "Jane Doe", + "bio": "Fitness coach & nutritionist", + "bannerUrl": "https://cdn.myfans.dev/banners/jane.jpg", + "category": "fitness" + }' +``` + +`201 Created`: + +```json +{ + "handle": "jane_doe", + "displayName": "Jane Doe", + "bio": "Fitness coach & nutritionist", + "bannerUrl": "https://cdn.myfans.dev/banners/jane.jpg", + "category": "fitness", + "createdAt": "2026-06-20T10:00:00.000Z", + "id": "0f9b...", + "userId": 3, + "isOnboarded": true, + "updatedAt": "2026-06-20T10:00:00.000Z" +} +``` + +Errors: + +- `400 Bad Request` — handle does not match `^[a-z0-9_]{3,30}$`. +- `401 Unauthorized` — missing/invalid access token. +- `404 Not Found` — authenticated user no longer exists. +- `409 Conflict` — handle already taken, or the user has already onboarded. + +### GET /creators/:handle + +Public profile lookup. No authentication required and never returns +email/password or internal ids. + +```bash +curl http://localhost:3000/creators/jane_doe +``` + +`200 OK`: + +```json +{ + "handle": "jane_doe", + "displayName": "Jane Doe", + "bio": "Fitness coach & nutritionist", + "bannerUrl": "https://cdn.myfans.dev/banners/jane.jpg", + "category": "fitness", + "createdAt": "2026-06-20T10:00:00.000Z" +} +``` + +Errors: + +- `404 Not Found` — no creator with that handle. + +### PATCH /creators/me + +Owner updates their own profile. The handle is immutable. Only the owner can +edit their profile; a caller without a profile gets `404`, and ownership is +enforced server-side (`403` on mismatch). + +```bash +curl -X PATCH http://localhost:3000/creators/me \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "bio": "Updated bio", + "bannerUrl": "https://cdn.myfans.dev/banners/jane-v2.jpg" + }' +``` + +`200 OK` returns the updated `CreatorPrivateDto`. + +Errors: + +- `401 Unauthorized` — missing/invalid access token. +- `403 Forbidden` — caller does not own the targeted profile. +- `404 Not Found` — caller has not onboarded as a creator. + +## Seeding + +`npm run seed:dev` seeds demo users and one creator profile +(`handle: creator_one`) linked to `creator1@dev.local`. Add `--fresh` to reset +the users table first. diff --git a/src/app.module.ts b/src/app.module.ts index cbafcaa..91a4506 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module'; +import { CreatorsModule } from './creators/creators.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { CacheModule } from '@nestjs/cache-manager'; diff --git a/src/creators/creator-profile.entity.ts b/src/creators/creator-profile.entity.ts new file mode 100644 index 0000000..bc09cd0 --- /dev/null +++ b/src/creators/creator-profile.entity.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../users/user.entity'; + +/** + * Public creator identity layered on top of the base User record (1:1). + * + * A User exists for every account; a CreatorProfile only exists once that user + * onboards as a creator. The handle is the public, URL-safe identifier used in + * discovery flows and is unique across all creators. + */ +@Entity('creator_profiles') +@Index(['userId'], { unique: true }) +@Index(['handle'], { unique: true }) +export class CreatorProfile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'int' }) + userId: number; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'varchar', length: 30, unique: true }) + handle: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + displayName: string | null; + + @Column({ type: 'varchar', length: 300, nullable: true }) + bio: string | null; + + @Column({ type: 'varchar', nullable: true }) + bannerUrl: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + category: string | null; + + @Column({ type: 'boolean', default: false }) + isOnboarded: boolean; + + @CreateDateColumn({ type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp' }) + updatedAt: Date; +} diff --git a/src/creators/creators.controller.ts b/src/creators/creators.controller.ts new file mode 100644 index 0000000..4caccf0 --- /dev/null +++ b/src/creators/creators.controller.ts @@ -0,0 +1,95 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { Request } from 'express'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CreatorsService } from './creators.service'; +import { OnboardCreatorDto } from './dtos/onboard-creator.dto'; +import { UpdateCreatorDto } from './dtos/update-creator.dto'; +import { CreatorResponseDto } from './dtos/creator-response.dto'; +import { CreatorPrivateDto } from './dtos/creator-private.dto'; + +interface AuthenticatedRequest extends Request { + user: { + userId: number; + email: string; + username: string; + }; +} + +@ApiTags('Creators') +@Controller('creators') +export class CreatorsController { + constructor(private readonly creatorsService: CreatorsService) {} + + @Post('onboard') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Authenticated user onboards as a creator' }) + @ApiResponse({ + status: 201, + description: 'Creator profile created; user role upgraded to creator', + type: CreatorPrivateDto, + }) + @ApiResponse({ status: 400, description: 'Invalid handle format' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'User not found' }) + @ApiResponse({ + status: 409, + description: 'Handle already taken or user already onboarded', + }) + async onboard( + @Req() req: AuthenticatedRequest, + @Body() dto: OnboardCreatorDto, + ): Promise { + return this.creatorsService.onboard(req.user.userId, dto); + } + + @Patch('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Owner updates their own creator profile' }) + @ApiResponse({ + status: 200, + description: 'Updated creator profile', + type: CreatorPrivateDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Caller does not own the profile' }) + @ApiResponse({ status: 404, description: 'Caller has no creator profile' }) + async updateMe( + @Req() req: AuthenticatedRequest, + @Body() dto: UpdateCreatorDto, + ): Promise { + return this.creatorsService.updateOwnProfile(req.user.userId, dto); + } + + @Get(':handle') + @ApiOperation({ summary: 'Public creator profile by handle (no auth)' }) + @ApiParam({ name: 'handle', description: 'Public creator handle' }) + @ApiResponse({ + status: 200, + description: 'Public creator profile', + type: CreatorResponseDto, + }) + @ApiResponse({ status: 404, description: 'Creator not found' }) + async getByHandle( + @Param('handle') handle: string, + ): Promise { + return this.creatorsService.getByHandle(handle); + } +} diff --git a/src/creators/creators.service.spec.ts b/src/creators/creators.service.spec.ts new file mode 100644 index 0000000..06ea1a7 --- /dev/null +++ b/src/creators/creators.service.spec.ts @@ -0,0 +1,231 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { CreatorsService } from './creators.service'; +import { CreatorProfile } from './creator-profile.entity'; +import { User } from '../users/user.entity'; + +describe('CreatorsService', () => { + let service: CreatorsService; + let creatorRepo: jest.Mocked>; + let userRepo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreatorsService, + { + provide: getRepositoryToken(CreatorProfile), + useValue: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User), + useValue: { + findOne: jest.fn(), + save: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(CreatorsService); + creatorRepo = module.get(getRepositoryToken(CreatorProfile)); + userRepo = module.get(getRepositoryToken(User)); + }); + + describe('onboard', () => { + it('rejects an invalid handle format before touching the database', async () => { + await expect( + service.onboard(1, { handle: 'Invalid Handle!' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(userRepo.findOne).not.toHaveBeenCalled(); + }); + + it('throws NotFound when the user does not exist', async () => { + userRepo.findOne.mockResolvedValue(null); + + await expect( + service.onboard(1, { handle: 'jane_doe' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns 409 when the user has already onboarded', async () => { + userRepo.findOne.mockResolvedValue({ id: 1, role: 'creator' } as User); + creatorRepo.findOne.mockResolvedValueOnce({ + id: 'uuid-1', + userId: 1, + } as CreatorProfile); + + await expect( + service.onboard(1, { handle: 'jane_doe' }), + ).rejects.toBeInstanceOf(ConflictException); + expect(creatorRepo.save).not.toHaveBeenCalled(); + }); + + it('returns 409 when the handle is already taken', async () => { + userRepo.findOne.mockResolvedValue({ id: 1, role: 'user' } as User); + creatorRepo.findOne + .mockResolvedValueOnce(null) // no existing profile for this user + .mockResolvedValueOnce({ + id: 'uuid-2', + userId: 2, + handle: 'jane_doe', + } as CreatorProfile); // handle taken by someone else + + await expect( + service.onboard(1, { handle: 'jane_doe' }), + ).rejects.toBeInstanceOf(ConflictException); + expect(creatorRepo.save).not.toHaveBeenCalled(); + }); + + it('creates the profile, upgrades the user role and returns the private dto', async () => { + const user = { id: 1, role: 'user' } as User; + userRepo.findOne.mockResolvedValue(user); + creatorRepo.findOne.mockResolvedValue(null); + creatorRepo.create.mockImplementation((p) => p as CreatorProfile); + creatorRepo.save.mockImplementation(async (p) => ({ + ...(p as CreatorProfile), + id: 'uuid-new', + createdAt: new Date(), + updatedAt: new Date(), + })); + + const result = await service.onboard(1, { + handle: 'jane_doe', + displayName: 'Jane', + bio: 'Coach', + }); + + expect(result).toMatchObject({ + id: 'uuid-new', + userId: 1, + handle: 'jane_doe', + displayName: 'Jane', + bio: 'Coach', + isOnboarded: true, + }); + // RBAC upgrade persisted. + expect(user.role).toBe('creator'); + expect(userRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ role: 'creator' }), + ); + // Private dto must never carry account credentials. + expect(result).not.toHaveProperty('email'); + expect(result).not.toHaveProperty('password'); + }); + + it('normalizes the handle by trimming and lowercasing', async () => { + userRepo.findOne.mockResolvedValue({ id: 1, role: 'creator' } as User); + creatorRepo.findOne.mockResolvedValue(null); + creatorRepo.create.mockImplementation((p) => p as CreatorProfile); + creatorRepo.save.mockImplementation(async (p) => ({ + ...(p as CreatorProfile), + id: 'uuid-new', + })); + + const result = await service.onboard(1, { handle: ' Jane_DOE ' }); + + expect(result.handle).toBe('jane_doe'); + }); + }); + + describe('getByHandle', () => { + it('returns the public dto without email/password or userId', async () => { + creatorRepo.findOne.mockResolvedValue({ + id: 'uuid-1', + userId: 7, + handle: 'jane_doe', + displayName: 'Jane', + bio: 'Coach', + bannerUrl: null, + category: 'fitness', + isOnboarded: true, + createdAt: new Date(), + updatedAt: new Date(), + } as CreatorProfile); + + const result = await service.getByHandle('jane_doe'); + + expect(result).toEqual({ + handle: 'jane_doe', + displayName: 'Jane', + bio: 'Coach', + bannerUrl: null, + category: 'fitness', + createdAt: expect.any(Date), + }); + expect(result).not.toHaveProperty('userId'); + expect(result).not.toHaveProperty('email'); + expect(result).not.toHaveProperty('password'); + expect(result).not.toHaveProperty('id'); + }); + + it('throws NotFound when no creator has the handle', async () => { + creatorRepo.findOne.mockResolvedValue(null); + + await expect(service.getByHandle('ghost')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); + + describe('updateOwnProfile', () => { + it('updates owner-editable fields and returns the private dto', async () => { + const profile = { + id: 'uuid-1', + userId: 1, + handle: 'jane_doe', + displayName: 'Jane', + bio: 'old bio', + bannerUrl: null, + category: null, + isOnboarded: true, + createdAt: new Date(), + updatedAt: new Date(), + } as CreatorProfile; + creatorRepo.findOne.mockResolvedValue(profile); + creatorRepo.save.mockImplementation(async (p) => p as CreatorProfile); + + const result = await service.updateOwnProfile(1, { + bio: 'new bio', + bannerUrl: 'https://cdn.myfans.dev/banner.jpg', + }); + + expect(result.bio).toBe('new bio'); + expect(result.bannerUrl).toBe('https://cdn.myfans.dev/banner.jpg'); + expect(result.handle).toBe('jane_doe'); + }); + + it('throws NotFound when the caller has no creator profile', async () => { + creatorRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateOwnProfile(1, { bio: 'x' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws Forbidden (403) when the profile is owned by another user', async () => { + // Defense-in-depth: the loaded profile belongs to user 2, not the caller. + creatorRepo.findOne.mockResolvedValue({ + id: 'uuid-1', + userId: 2, + handle: 'jane_doe', + } as CreatorProfile); + + await expect( + service.updateOwnProfile(1, { bio: 'x' }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(creatorRepo.save).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/creators/creators.service.ts b/src/creators/creators.service.ts new file mode 100644 index 0000000..74aa17e --- /dev/null +++ b/src/creators/creators.service.ts @@ -0,0 +1,151 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreatorProfile } from './creator-profile.entity'; +import { User } from '../users/user.entity'; +import { OnboardCreatorDto, HANDLE_REGEX } from './dtos/onboard-creator.dto'; +import { UpdateCreatorDto } from './dtos/update-creator.dto'; +import { CreatorResponseDto } from './dtos/creator-response.dto'; +import { CreatorPrivateDto } from './dtos/creator-private.dto'; + +@Injectable() +export class CreatorsService { + constructor( + @InjectRepository(CreatorProfile) + private readonly creatorRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + /** + * An authenticated user onboards as a creator: validates the handle, ensures + * the user has not already onboarded, ensures the handle is free, persists the + * profile and upgrades the user's role to `creator`. + */ + async onboard( + userId: number, + dto: OnboardCreatorDto, + ): Promise { + const handle = this.normalizeHandle(dto.handle); + + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const existingForUser = await this.creatorRepository.findOne({ + where: { userId }, + }); + if (existingForUser) { + throw new ConflictException('User has already onboarded as a creator'); + } + + const handleTaken = await this.creatorRepository.findOne({ + where: { handle }, + }); + if (handleTaken) { + throw new ConflictException(`Handle "${handle}" is already taken`); + } + + const profile = this.creatorRepository.create({ + userId, + handle, + displayName: dto.displayName ?? null, + bio: dto.bio ?? null, + bannerUrl: dto.bannerUrl ?? null, + category: dto.category ?? null, + isOnboarded: true, + }); + const saved = await this.creatorRepository.save(profile); + + // RBAC role upgrade: a fan account becomes a creator account. + if (user.role !== 'creator') { + user.role = 'creator'; + await this.userRepository.save(user); + } + + return this.toPrivate(saved); + } + + /** + * Public profile lookup by handle. Never exposes email/password or internal + * identifiers. + */ + async getByHandle(handle: string): Promise { + const profile = await this.creatorRepository.findOne({ + where: { handle: this.normalizeHandle(handle) }, + }); + if (!profile) { + throw new NotFoundException('Creator not found'); + } + return this.toPublic(profile); + } + + /** + * Owner updates their own profile. Ownership is enforced both by looking the + * profile up via the authenticated user id and by an explicit assertion, so a + * non-owner can never mutate another creator's profile (403). + */ + async updateOwnProfile( + userId: number, + dto: UpdateCreatorDto, + ): Promise { + const profile = await this.creatorRepository.findOne({ where: { userId } }); + if (!profile) { + throw new NotFoundException('You have not onboarded as a creator'); + } + + this.assertOwnership(profile, userId); + + if (dto.displayName !== undefined) profile.displayName = dto.displayName; + if (dto.bio !== undefined) profile.bio = dto.bio; + if (dto.bannerUrl !== undefined) profile.bannerUrl = dto.bannerUrl; + if (dto.category !== undefined) profile.category = dto.category; + + const saved = await this.creatorRepository.save(profile); + return this.toPrivate(saved); + } + + private assertOwnership(profile: CreatorProfile, userId: number): void { + if (profile.userId !== userId) { + throw new ForbiddenException('You do not own this creator profile'); + } + } + + private normalizeHandle(handle: string): string { + const normalized = (handle ?? '').trim().toLowerCase(); + if (!HANDLE_REGEX.test(normalized)) { + throw new BadRequestException( + 'handle must match ^[a-z0-9_]{3,30}$ (lowercase letters, digits, underscores, 3-30 chars)', + ); + } + return normalized; + } + + private toPublic(profile: CreatorProfile): CreatorResponseDto { + return { + handle: profile.handle, + displayName: profile.displayName, + bio: profile.bio, + bannerUrl: profile.bannerUrl, + category: profile.category, + createdAt: profile.createdAt, + }; + } + + private toPrivate(profile: CreatorProfile): CreatorPrivateDto { + return { + ...this.toPublic(profile), + id: profile.id, + userId: profile.userId, + isOnboarded: profile.isOnboarded, + updatedAt: profile.updatedAt, + }; + } +} diff --git a/src/creators/dtos/creator-private.dto.ts b/src/creators/dtos/creator-private.dto.ts new file mode 100644 index 0000000..538385c --- /dev/null +++ b/src/creators/dtos/creator-private.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CreatorResponseDto } from './creator-response.dto'; + +/** + * Owner view returned by POST /creators/onboard and PATCH /creators/me. + * + * Extends the public view with owner-only metadata (id, userId, onboarding + * status, updatedAt). Still never includes email/password. + */ +export class CreatorPrivateDto extends CreatorResponseDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ description: 'ID of the owning user account' }) + userId: number; + + @ApiProperty({ example: true }) + isOnboarded: boolean; + + @ApiProperty({ type: String, format: 'date-time' }) + updatedAt: Date; +} diff --git a/src/creators/dtos/creator-response.dto.ts b/src/creators/dtos/creator-response.dto.ts new file mode 100644 index 0000000..c4750c9 --- /dev/null +++ b/src/creators/dtos/creator-response.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * Public creator view returned by GET /creators/:handle. + * + * Deliberately excludes userId and any User fields (email/password) so the + * public endpoint never leaks account credentials or internal identifiers. + */ +export class CreatorResponseDto { + @ApiProperty({ example: 'jane_doe' }) + handle: string; + + @ApiProperty({ nullable: true, example: 'Jane Doe' }) + displayName: string | null; + + @ApiProperty({ nullable: true, example: 'Fitness coach & nutritionist' }) + bio: string | null; + + @ApiProperty({ nullable: true, example: 'https://cdn.myfans.dev/banner.jpg' }) + bannerUrl: string | null; + + @ApiProperty({ nullable: true, example: 'fitness' }) + category: string | null; + + @ApiProperty({ type: String, format: 'date-time' }) + createdAt: Date; +} diff --git a/src/creators/dtos/onboard-creator.dto.spec.ts b/src/creators/dtos/onboard-creator.dto.spec.ts new file mode 100644 index 0000000..5dee904 --- /dev/null +++ b/src/creators/dtos/onboard-creator.dto.spec.ts @@ -0,0 +1,61 @@ +import { validate } from 'class-validator'; +import { OnboardCreatorDto, HANDLE_REGEX } from './onboard-creator.dto'; + +function makeDto(handle: string): OnboardCreatorDto { + const dto = new OnboardCreatorDto(); + dto.handle = handle; + return dto; +} + +describe('OnboardCreatorDto handle validation', () => { + const validHandles = ['abc', 'jane_doe', 'user_123', 'a'.repeat(30), '___']; + const invalidHandles = [ + 'ab', // too short (2) + 'a'.repeat(31), // too long (31) + 'Jane', // uppercase + 'jane doe', // space + 'jane-doe', // hyphen + 'jane.doe', // dot + 'jane!', // special char + '', // empty + ]; + + it.each(validHandles)('accepts valid handle "%s"', async (handle) => { + const errors = await validate(makeDto(handle)); + expect(errors).toHaveLength(0); + expect(HANDLE_REGEX.test(handle)).toBe(true); + }); + + it.each(invalidHandles)('rejects invalid handle "%s"', async (handle) => { + const errors = await validate(makeDto(handle)); + expect(errors.some((e) => e.property === 'handle')).toBe(true); + expect(HANDLE_REGEX.test(handle)).toBe(false); + }); + + it('passes with valid optional metadata', async () => { + const dto = makeDto('jane_doe'); + dto.displayName = 'Jane Doe'; + dto.bio = 'Fitness coach'; + dto.bannerUrl = 'https://cdn.myfans.dev/banner.jpg'; + dto.category = 'fitness'; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects an invalid bannerUrl', async () => { + const dto = makeDto('jane_doe'); + dto.bannerUrl = 'not-a-url'; + + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'bannerUrl')).toBe(true); + }); + + it('rejects a bio longer than 300 characters', async () => { + const dto = makeDto('jane_doe'); + dto.bio = 'a'.repeat(301); + + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'bio')).toBe(true); + }); +}); diff --git a/src/creators/dtos/onboard-creator.dto.ts b/src/creators/dtos/onboard-creator.dto.ts new file mode 100644 index 0000000..1c119de --- /dev/null +++ b/src/creators/dtos/onboard-creator.dto.ts @@ -0,0 +1,52 @@ +import { + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Lowercase letters, digits and underscores only, 3-30 characters. + * Shared by the DTO validation, the service and the unit tests. + */ +export const HANDLE_REGEX = /^[a-z0-9_]{3,30}$/; + +export class OnboardCreatorDto { + @ApiProperty({ + description: + 'Public, URL-safe handle. Lowercase letters, digits and underscores, 3-30 chars.', + pattern: '^[a-z0-9_]{3,30}$', + example: 'jane_doe', + }) + @IsString() + @Matches(HANDLE_REGEX, { + message: + 'handle must match ^[a-z0-9_]{3,30}$ (lowercase letters, digits, underscores, 3-30 chars)', + }) + handle: string; + + @ApiPropertyOptional({ description: 'Public display name' }) + @IsOptional() + @IsString() + @MaxLength(100) + displayName?: string; + + @ApiPropertyOptional({ description: 'Short bio (max 300 characters)' }) + @IsOptional() + @IsString() + @MaxLength(300) + bio?: string; + + @ApiPropertyOptional({ description: 'URL to the banner image' }) + @IsOptional() + @IsUrl() + bannerUrl?: string; + + @ApiPropertyOptional({ description: 'Creator category', example: 'fitness' }) + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; +} diff --git a/src/creators/dtos/update-creator.dto.ts b/src/creators/dtos/update-creator.dto.ts new file mode 100644 index 0000000..7b47999 --- /dev/null +++ b/src/creators/dtos/update-creator.dto.ts @@ -0,0 +1,31 @@ +import { IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Owner-editable fields. The handle is immutable after onboarding so it can + * stay a stable public identifier. + */ +export class UpdateCreatorDto { + @ApiPropertyOptional({ description: 'Public display name' }) + @IsOptional() + @IsString() + @MaxLength(100) + displayName?: string; + + @ApiPropertyOptional({ description: 'Short bio (max 300 characters)' }) + @IsOptional() + @IsString() + @MaxLength(300) + bio?: string; + + @ApiPropertyOptional({ description: 'URL to the banner image' }) + @IsOptional() + @IsUrl() + bannerUrl?: string; + + @ApiPropertyOptional({ description: 'Creator category', example: 'fitness' }) + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; +} diff --git a/src/migrations/1769050000000-CreateCreatorProfiles.ts b/src/migrations/1769050000000-CreateCreatorProfiles.ts new file mode 100644 index 0000000..796a857 --- /dev/null +++ b/src/migrations/1769050000000-CreateCreatorProfiles.ts @@ -0,0 +1,112 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class CreateCreatorProfiles1769050000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'creator_profiles', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + isGenerated: true, + }, + { + name: 'userId', + type: 'int', + }, + { + name: 'handle', + type: 'varchar', + length: '30', + }, + { + name: 'displayName', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'bio', + type: 'varchar', + length: '300', + isNullable: true, + }, + { + name: 'bannerUrl', + type: 'varchar', + isNullable: true, + }, + { + name: 'category', + type: 'varchar', + length: '50', + isNullable: true, + }, + { + name: 'isOnboarded', + type: 'boolean', + default: false, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + ], + }), + ); + + await queryRunner.createForeignKey( + 'creator_profiles', + new TableForeignKey({ + columnNames: ['userId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + // 1:1 with users — one creator profile per account. + await queryRunner.createIndex( + 'creator_profiles', + new TableIndex({ + name: 'UQ_CREATOR_PROFILES_USER', + columnNames: ['userId'], + isUnique: true, + }), + ); + + // Unique public handle used in discovery flows. + await queryRunner.createIndex( + 'creator_profiles', + new TableIndex({ + name: 'UQ_CREATOR_PROFILES_HANDLE', + columnNames: ['handle'], + isUnique: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex( + 'creator_profiles', + 'UQ_CREATOR_PROFILES_HANDLE', + ); + await queryRunner.dropIndex('creator_profiles', 'UQ_CREATOR_PROFILES_USER'); + await queryRunner.dropTable('creator_profiles'); + } +}