Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions docs/creators-api.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
57 changes: 57 additions & 0 deletions src/creators/creator-profile.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
95 changes: 95 additions & 0 deletions src/creators/creators.controller.ts
Original file line number Diff line number Diff line change
@@ -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<CreatorPrivateDto> {
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<CreatorPrivateDto> {
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<CreatorResponseDto> {
return this.creatorsService.getByHandle(handle);
}
}
Loading