Skip to content

Commit 6486845

Browse files
committed
feat(posts): implement Posts module — closes InsurNiffy#1000, InsurNiffy#1001, InsurNiffy#1002, InsurNiffy#1004
## What was done ### InsurNiffy#1000 — DTO validation tests for invalid input - Added Zod schemas (CreatePostDtoSchema, UpdatePostDtoSchema, PostsQueryDtoSchema) in backend/src/posts/dto/post.dto.ts - Added backend/src/posts/dto/post.dto.spec.ts with 30+ unit tests covering: missing fields, empty strings, length limits, invalid enum values, malformed Stellar addresses, out-of-range limits, and non-integer values. ### InsurNiffy#1001 — E2E test for primary endpoint - Added backend/test/e2e/posts.e2e-spec.ts covering all five HTTP verbs (GET list, GET by id, POST, PATCH, DELETE) including auth guards (401), validation rejections (400), not-found (404), and happy-path shape assertions. ### InsurNiffy#1002 — Pagination / limit query support - PostsService.listPosts() uses existing cursor-based pagination helpers (buildKeysetWhere, buildNextCursor, clampLimit) from src/helpers/pagination.ts. - Controller accepts after, limit, status, and authorAddress query params; limit is validated (1–100) via PostsQueryDtoSchema. - Post model in prisma/schema.prisma has @@index([createdAt, id]) for efficient keyset pagination ORDER BY createdAt DESC, id DESC. ### InsurNiffy#1004 — Swagger/OpenAPI documentation - PostsController decorates every route with @apitags, @apioperation, @apiresponse, @apiquery, and @ApiBearerAuth — all picked up automatically by SwaggerModule at /api/docs. - src/openapi/spec.ts extended with /posts and /posts/{id} path objects and PostDto, PostsListDto, CreatePostDto, UpdatePostDto component schemas. ## How it was done - Followed existing module patterns (claims, policy): Zod for inbound validation, class-validator DTOs for outbound serialisation, NestJS guards for auth, and @nestjs/throttler for rate-limiting on mutations. - PostsModule registered in AppModule; Post model + PostStatus enum added to prisma/schema.prisma with soft-delete (deletedAt) matching the project convention.
1 parent c479b5a commit 6486845

9 files changed

Lines changed: 1091 additions & 0 deletions

File tree

backend/prisma/schema.prisma

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,3 +434,27 @@ model HolderProfile {
434434
@@index([lastSeenAt])
435435
@@map("holder_profiles")
436436
}
437+
438+
/// Off-chain user-generated posts. Supports soft-delete via deletedAt.
439+
model Post {
440+
id Int @id @default(autoincrement())
441+
title String @db.VarChar(200)
442+
body String @db.VarChar(10000)
443+
status PostStatus @default(DRAFT)
444+
authorAddress String @map("author_address")
445+
createdAt DateTime @default(now()) @map("created_at")
446+
updatedAt DateTime @updatedAt @map("updated_at")
447+
deletedAt DateTime? @map("deleted_at")
448+
449+
@@index([status])
450+
@@index([authorAddress])
451+
@@index([deletedAt])
452+
@@index([createdAt, id]) // keyset pagination: ORDER BY createdAt DESC, id DESC
453+
@@map("posts")
454+
}
455+
456+
enum PostStatus {
457+
DRAFT
458+
PUBLISHED
459+
ARCHIVED
460+
}

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { V1SunsetInterceptor } from './common/versioning/v1-sunset.interceptor';
4040
import { RejectUnversionedApiMiddleware } from './common/versioning/reject-unversioned-api.middleware';
4141
import { LastSeenInterceptor } from './common/interceptors/last-seen.interceptor';
4242
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
43+
import { PostsModule } from './posts/posts.module';
4344

4445
/** Mutation routes that require idempotency key support (issue #363). */
4546
const IDEMPOTENCY_ROUTES = [
@@ -93,6 +94,7 @@ const IDEMPOTENCY_ROUTES = [
9394
ProfileModule,
9495
FeedsModule,
9596
AssetsModule,
97+
PostsModule,
9698
],
9799
controllers: [OracleHooksController, BetaCalculatorsController, XdrDecodeController],
98100
providers: [

backend/src/openapi/spec.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const openapiSpec = {
1818
tags: [
1919
{ name: "Policies", description: "Policy lifecycle and listing" },
2020
{ name: "Claims", description: "Claim filing, listing, and voting" },
21+
{ name: "Posts", description: "User-generated posts (CRUD)" },
2122
],
2223
paths: {
2324
"/claims": {
@@ -49,6 +50,149 @@ export const openapiSpec = {
4950
},
5051
},
5152
},
53+
"/posts": {
54+
get: {
55+
summary: "List posts",
56+
operationId: "listPosts",
57+
tags: ["Posts"],
58+
parameters: [
59+
{ $ref: "#/components/parameters/after" },
60+
{ $ref: "#/components/parameters/limit" },
61+
{
62+
name: "status",
63+
in: "query",
64+
description: "Filter by post status.",
65+
schema: { type: "string", enum: ["draft", "published", "archived"] },
66+
},
67+
{
68+
name: "authorAddress",
69+
in: "query",
70+
description: "Filter by author Stellar address.",
71+
schema: { type: "string", example: "GABC1111111111111111111111111111111111111111111111111111" },
72+
},
73+
],
74+
responses: {
75+
"200": {
76+
description: "Paginated post list",
77+
content: {
78+
"application/json": {
79+
schema: { $ref: "#/components/schemas/PostsListDto" },
80+
},
81+
},
82+
},
83+
"400": { $ref: "#/components/responses/BadRequest" },
84+
"429": { $ref: "#/components/responses/RateLimited" },
85+
},
86+
},
87+
post: {
88+
summary: "Create a post",
89+
operationId: "createPost",
90+
tags: ["Posts"],
91+
security: [{ bearerAuth: [] }],
92+
requestBody: {
93+
required: true,
94+
content: {
95+
"application/json": {
96+
schema: { $ref: "#/components/schemas/CreatePostDto" },
97+
},
98+
},
99+
},
100+
responses: {
101+
"201": {
102+
description: "Post created",
103+
content: {
104+
"application/json": {
105+
schema: { $ref: "#/components/schemas/PostDto" },
106+
},
107+
},
108+
},
109+
"400": { $ref: "#/components/responses/BadRequest" },
110+
"401": { description: "Unauthorized" },
111+
"429": { $ref: "#/components/responses/RateLimited" },
112+
},
113+
},
114+
},
115+
"/posts/{id}": {
116+
get: {
117+
summary: "Get a single post",
118+
operationId: "getPost",
119+
tags: ["Posts"],
120+
parameters: [
121+
{
122+
name: "id",
123+
in: "path",
124+
required: true,
125+
description: "Post numeric identifier.",
126+
schema: { type: "integer", minimum: 1, example: 1 },
127+
},
128+
],
129+
responses: {
130+
"200": {
131+
description: "Post detail",
132+
content: {
133+
"application/json": {
134+
schema: { $ref: "#/components/schemas/PostDto" },
135+
},
136+
},
137+
},
138+
"404": { $ref: "#/components/responses/NotFound" },
139+
},
140+
},
141+
patch: {
142+
summary: "Update a post (partial)",
143+
operationId: "updatePost",
144+
tags: ["Posts"],
145+
security: [{ bearerAuth: [] }],
146+
parameters: [
147+
{
148+
name: "id",
149+
in: "path",
150+
required: true,
151+
schema: { type: "integer", minimum: 1 },
152+
},
153+
],
154+
requestBody: {
155+
required: true,
156+
content: {
157+
"application/json": {
158+
schema: { $ref: "#/components/schemas/UpdatePostDto" },
159+
},
160+
},
161+
},
162+
responses: {
163+
"200": {
164+
description: "Post updated",
165+
content: {
166+
"application/json": {
167+
schema: { $ref: "#/components/schemas/PostDto" },
168+
},
169+
},
170+
},
171+
"400": { $ref: "#/components/responses/BadRequest" },
172+
"401": { description: "Unauthorized" },
173+
"404": { $ref: "#/components/responses/NotFound" },
174+
},
175+
},
176+
delete: {
177+
summary: "Soft-delete a post",
178+
operationId: "deletePost",
179+
tags: ["Posts"],
180+
security: [{ bearerAuth: [] }],
181+
parameters: [
182+
{
183+
name: "id",
184+
in: "path",
185+
required: true,
186+
schema: { type: "integer", minimum: 1 },
187+
},
188+
],
189+
responses: {
190+
"204": { description: "Post deleted" },
191+
"401": { description: "Unauthorized" },
192+
"404": { $ref: "#/components/responses/NotFound" },
193+
},
194+
},
195+
},
52196
"/policies": {
53197
get: {
54198
summary: "List policies",
@@ -371,6 +515,45 @@ export const openapiSpec = {
371515
pagination: { $ref: "#/components/schemas/CursorPageDto" },
372516
},
373517
},
518+
PostDto: {
519+
type: "object",
520+
required: ["id", "title", "body", "status", "authorAddress", "createdAt", "updatedAt"],
521+
properties: {
522+
id: { type: "integer", example: 1 },
523+
title: { type: "string", maxLength: 200, example: "My first post" },
524+
body: { type: "string", maxLength: 10000, example: "This is the post content." },
525+
status: { type: "string", enum: ["draft", "published", "archived"], example: "published" },
526+
authorAddress: { type: "string", example: "GABC1111111111111111111111111111111111111111111111111111" },
527+
createdAt: { type: "string", format: "date-time" },
528+
updatedAt: { type: "string", format: "date-time" },
529+
},
530+
},
531+
PostsListDto: {
532+
type: "object",
533+
required: ["data", "pagination"],
534+
properties: {
535+
data: { type: "array", items: { $ref: "#/components/schemas/PostDto" } },
536+
pagination: { $ref: "#/components/schemas/CursorPageDto" },
537+
},
538+
},
539+
CreatePostDto: {
540+
type: "object",
541+
required: ["title", "body", "authorAddress"],
542+
properties: {
543+
title: { type: "string", minLength: 1, maxLength: 200, example: "My post title" },
544+
body: { type: "string", minLength: 1, maxLength: 10000, example: "Post body content." },
545+
status: { type: "string", enum: ["draft", "published", "archived"], default: "draft" },
546+
authorAddress: { type: "string", example: "GABC1111111111111111111111111111111111111111111111111111" },
547+
},
548+
},
549+
UpdatePostDto: {
550+
type: "object",
551+
properties: {
552+
title: { type: "string", minLength: 1, maxLength: 200 },
553+
body: { type: "string", minLength: 1, maxLength: 10000 },
554+
status: { type: "string", enum: ["draft", "published", "archived"] },
555+
},
556+
},
374557
ApiError: {
375558
type: "object",
376559
required: ["error", "message"],

0 commit comments

Comments
 (0)