Skip to content

Commit 2c69e07

Browse files
committed
feat(api): add stream CRUD REST endpoints (#5)
Create StreamsModule with full CRUD (POST/GET/PATCH/DELETE /streams). Add AuthGuard placeholder. Status transition validation, Swagger docs, and class-validator DTOs included.
1 parent 6263ec1 commit 2c69e07

11 files changed

Lines changed: 465 additions & 0 deletions

api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AdminModule } from "./admin/admin.module"
55
import { AuditModule } from "./audit/audit.module"
66
import { GatewaysModule } from "./gateways/gateways.module"
77
import { HealthModule } from "./health/health.module"
8+
import { StreamsModule } from "./streams/streams.module"
89
import { TagsModule } from "./tags/tags.module"
910

1011
@Module({
@@ -19,6 +20,7 @@ import { TagsModule } from "./tags/tags.module"
1920
AuditModule,
2021
GatewaysModule,
2122
HealthModule,
23+
StreamsModule,
2224
TagsModule,
2325
],
2426
providers: [
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
Injectable,
5+
UnauthorizedException,
6+
} from "@nestjs/common"
7+
import type { Request } from "express"
8+
9+
/**
10+
* Lightweight auth guard that extracts the authenticated user id from the
11+
* `X-User-Id` header. This is a placeholder until the full JWT auth
12+
* pipeline lands — at that point the guard will read `req.user.sub`
13+
* instead.
14+
*
15+
* Apply with `@UseGuards(AuthGuard)` on controllers or individual
16+
* handlers that require authentication.
17+
*/
18+
@Injectable()
19+
export class AuthGuard implements CanActivate {
20+
canActivate(context: ExecutionContext): boolean {
21+
const req = context.switchToHttp().getRequest<Request>()
22+
23+
const rawUserId = (req.header("x-user-id") ?? "").trim()
24+
if (!rawUserId) {
25+
throw new UnauthorizedException(
26+
"X-User-Id header is required (placeholder until JWT auth lands)",
27+
)
28+
}
29+
const userId = Number(rawUserId)
30+
if (!Number.isInteger(userId) || userId <= 0) {
31+
throw new UnauthorizedException("X-User-Id must be a positive integer")
32+
}
33+
34+
// Stash on the request so downstream handlers / guards can access
35+
// the authenticated user without re-parsing.
36+
;(req as Request & { auth?: { userId: number } }).auth = { userId }
37+
return true
38+
}
39+
}

api/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ async function bootstrap() {
8181
"bearer",
8282
)
8383
.addTag("health", "Liveness and readiness probes")
84+
.addTag("streams", "Stream lifecycle CRUD")
8485
.build()
8586

8687
const document = SwaggerModule.createDocument(app, swaggerConfig)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { IsOptional, IsString, Length, MaxLength } from "class-validator"
2+
3+
/**
4+
* Payload accepted by `POST /streams`.
5+
*/
6+
export class CreateStreamDto {
7+
@IsString()
8+
@Length(1, 255, {
9+
message: "name must be between 1 and 255 characters",
10+
})
11+
name!: string
12+
13+
@IsOptional()
14+
@IsString()
15+
@MaxLength(2000, {
16+
message: "description must be at most 2000 characters",
17+
})
18+
description?: string
19+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ApiPropertyOptional } from "@nestjs/swagger"
2+
import { Type } from "class-transformer"
3+
import { IsIn, IsOptional, IsString } from "class-validator"
4+
import { PaginationQueryDto } from "../../common/dto/pagination.dto"
5+
6+
/**
7+
* Query parameters for `GET /streams`.
8+
*
9+
* Extends the shared {@link PaginationQueryDto} so paging behaviour is
10+
* consistent with the rest of the API. Adds an optional `status` filter.
11+
*/
12+
export class ListStreamsQueryDto extends PaginationQueryDto {
13+
@ApiPropertyOptional({
14+
description: "Filter streams by status (inactive, active, error)",
15+
example: "active",
16+
})
17+
@IsOptional()
18+
@IsString()
19+
@IsIn(["inactive", "active", "error"], {
20+
message: "status must be one of: inactive, active, error",
21+
})
22+
status?: string
23+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { IsOptional, IsString, Length, MaxLength } from "class-validator"
2+
3+
/**
4+
* Payload accepted by `PATCH /streams/:id`. All fields are optional;
5+
* only the supplied fields are updated.
6+
*/
7+
export class UpdateStreamDto {
8+
@IsOptional()
9+
@IsString()
10+
@Length(1, 255, {
11+
message: "name must be between 1 and 255 characters",
12+
})
13+
name?: string
14+
15+
@IsOptional()
16+
@IsString()
17+
@MaxLength(2000, {
18+
message: "description must be at most 2000 characters",
19+
})
20+
description?: string
21+
22+
@IsOptional()
23+
@IsString()
24+
@Length(1, 50, {
25+
message: "status must be between 1 and 50 characters",
26+
})
27+
status?: string
28+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Injectable } from "@nestjs/common"
2+
import { Stream } from "../stream.entity"
3+
4+
/**
5+
* In-memory streams repository.
6+
*
7+
* Persistence-agnostic: the controller and service depend only on the
8+
* public methods exposed here. When the Postgres layer is ready this
9+
* class can be swapped for a DB-backed repository without changing
10+
* higher layers.
11+
*/
12+
@Injectable()
13+
export class StreamsRepository {
14+
private readonly streamsById = new Map<number, Stream>()
15+
private nextId = 1
16+
17+
findById(id: number): Stream | undefined {
18+
return this.streamsById.get(id)
19+
}
20+
21+
/**
22+
* Returns all streams, optionally filtered by status.
23+
*/
24+
listFiltered(filter?: { status?: string }): Stream[] {
25+
let results = Array.from(this.streamsById.values())
26+
if (filter?.status) {
27+
results = results.filter((s) => s.status === filter.status)
28+
}
29+
return results.sort(
30+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
31+
)
32+
}
33+
34+
/**
35+
* Paginated listing.
36+
*/
37+
listPaginated(
38+
page: number,
39+
limit: number,
40+
filter?: { status?: string },
41+
): { items: Stream[]; total: number } {
42+
const filtered = this.listFiltered(filter)
43+
const offset = (page - 1) * limit
44+
return {
45+
items: filtered.slice(offset, offset + limit),
46+
total: filtered.length,
47+
}
48+
}
49+
50+
create(params: { userId: number; name: string; description?: string }): Stream {
51+
const stream: Stream = {
52+
id: this.nextId++,
53+
userId: params.userId,
54+
name: params.name,
55+
description: params.description ?? null,
56+
status: "inactive",
57+
createdAt: new Date(),
58+
updatedAt: new Date(),
59+
}
60+
this.streamsById.set(stream.id, stream)
61+
return stream
62+
}
63+
64+
update(
65+
id: number,
66+
changes: { name?: string; description?: string; status?: string },
67+
): Stream {
68+
const stream = this.streamsById.get(id)!
69+
if (changes.name !== undefined) stream.name = changes.name
70+
if (changes.description !== undefined) stream.description = changes.description
71+
if (changes.status !== undefined) {
72+
stream.status = changes.status as Stream["status"]
73+
}
74+
stream.updatedAt = new Date()
75+
return stream
76+
}
77+
78+
delete(id: number): boolean {
79+
return this.streamsById.delete(id)
80+
}
81+
}

api/src/streams/stream.entity.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* In-memory representation of a stream. Mirrors the `streams` table
3+
* defined in `database/schema.sql`. The controller and service layers
4+
* depend on this interface so they stay unchanged when the repository
5+
* is swapped for a real DB-backed implementation.
6+
*/
7+
export interface Stream {
8+
id: number
9+
userId: number
10+
name: string
11+
description: string | null
12+
status: "inactive" | "active" | "error"
13+
createdAt: Date
14+
updatedAt: Date
15+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
HttpCode,
7+
HttpStatus,
8+
Param,
9+
ParseIntPipe,
10+
Patch,
11+
Post,
12+
Query,
13+
Req,
14+
UseGuards,
15+
} from "@nestjs/common"
16+
import {
17+
ApiBearerAuth,
18+
ApiConflictResponse,
19+
ApiCreatedResponse,
20+
ApiNoContentResponse,
21+
ApiNotFoundResponse,
22+
ApiOkResponse,
23+
ApiOperation,
24+
ApiTags,
25+
} from "@nestjs/swagger"
26+
import type { Request } from "express"
27+
import { AuthGuard } from "../common/guards/auth.guard"
28+
import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard"
29+
import { CreateStreamDto } from "./dto/create-stream.dto"
30+
import { ListStreamsQueryDto } from "./dto/list-streams.query.dto"
31+
import { UpdateStreamDto } from "./dto/update-stream.dto"
32+
import { StreamsService } from "./streams.service"
33+
34+
/**
35+
* Full CRUD for streams.
36+
*
37+
* POST /streams Create a new stream (auth required)
38+
* GET /streams List streams (auth required, paginated)
39+
* GET /streams/:id Get a single stream (ownership required)
40+
* PATCH /streams/:id Update stream details (ownership required)
41+
* DELETE /streams/:id Delete a stream (ownership required)
42+
*/
43+
@ApiTags("streams")
44+
@ApiBearerAuth()
45+
@Controller("streams")
46+
export class StreamsController {
47+
constructor(private readonly streamsService: StreamsService) {}
48+
49+
/**
50+
* Create a new stream. The authenticated user becomes the owner.
51+
*/
52+
@Post()
53+
@HttpCode(HttpStatus.CREATED)
54+
@UseGuards(AuthGuard)
55+
@ApiOperation({
56+
summary: "Create a new stream",
57+
description: "Creates a new stream with the authenticated user as owner.",
58+
})
59+
@ApiCreatedResponse({ description: "Stream created successfully." })
60+
create(
61+
@Body() body: CreateStreamDto,
62+
@Req() req: Request & { auth?: { userId: number } },
63+
) {
64+
return this.streamsService.create({
65+
userId: req.auth!.userId,
66+
name: body.name,
67+
description: body.description,
68+
})
69+
}
70+
71+
/**
72+
* List all streams with optional status filter and pagination.
73+
*/
74+
@Get()
75+
@UseGuards(AuthGuard)
76+
@ApiOperation({
77+
summary: "List streams",
78+
description: "Returns a paginated list of streams with optional status filter.",
79+
})
80+
@ApiOkResponse({ description: "Paginated list of streams." })
81+
list(@Query() query: ListStreamsQueryDto) {
82+
const page = query.page ?? 1
83+
const limit = query.limit ?? 20
84+
return this.streamsService.list(page, limit, {
85+
status: query.status,
86+
})
87+
}
88+
89+
/**
90+
* Get a single stream by id. Requires stream ownership.
91+
*/
92+
@Get(":id")
93+
@UseGuards(StreamOwnershipGuard)
94+
@ApiOperation({
95+
summary: "Get a stream",
96+
description: "Returns a single stream by id. Requires ownership.",
97+
})
98+
@ApiOkResponse({ description: "Stream found." })
99+
@ApiNotFoundResponse({ description: "Stream not found." })
100+
findById(@Param("id", ParseIntPipe) id: number) {
101+
return this.streamsService.findById(id)
102+
}
103+
104+
/**
105+
* Update stream details (name, description, status).
106+
* Requires stream ownership.
107+
*/
108+
@Patch(":id")
109+
@UseGuards(StreamOwnershipGuard)
110+
@ApiOperation({
111+
summary: "Update a stream",
112+
description: "Partially updates a stream. Requires ownership.",
113+
})
114+
@ApiOkResponse({ description: "Stream updated." })
115+
@ApiNotFoundResponse({ description: "Stream not found." })
116+
@ApiConflictResponse({ description: "Invalid status transition." })
117+
update(
118+
@Param("id", ParseIntPipe) id: number,
119+
@Body() body: UpdateStreamDto,
120+
) {
121+
return this.streamsService.update(id, body)
122+
}
123+
124+
/**
125+
* Delete a stream. Requires stream ownership.
126+
*/
127+
@Delete(":id")
128+
@HttpCode(HttpStatus.NO_CONTENT)
129+
@UseGuards(StreamOwnershipGuard)
130+
@ApiOperation({
131+
summary: "Delete a stream",
132+
description: "Deletes a stream by id. Requires ownership.",
133+
})
134+
@ApiNoContentResponse({ description: "Stream deleted." })
135+
@ApiNotFoundResponse({ description: "Stream not found." })
136+
delete(@Param("id", ParseIntPipe) id: number): void {
137+
this.streamsService.delete(id)
138+
}
139+
}

api/src/streams/streams.module.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Module } from "@nestjs/common"
2+
import { AuthGuard } from "../common/guards/auth.guard"
3+
import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard"
4+
import { StreamOwnershipService } from "../common/guards/stream-ownership.service"
5+
import { StreamsRepository } from "./repository/streams.repository"
6+
import { StreamsController } from "./streams.controller"
7+
import { StreamsService } from "./streams.service"
8+
9+
@Module({
10+
controllers: [StreamsController],
11+
providers: [
12+
StreamsService,
13+
StreamsRepository,
14+
AuthGuard,
15+
StreamOwnershipGuard,
16+
StreamOwnershipService,
17+
],
18+
exports: [StreamsService],
19+
})
20+
export class StreamsModule {}

0 commit comments

Comments
 (0)