Skip to content
Closed
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
22 changes: 22 additions & 0 deletions prisma/migrations/20260830000000_add_post_quotes/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Quote posts: a post can now embed another post, the way a quote tweet does.
--
-- The foreign key is self-referential and cascades. Deleting a post therefore
-- deletes every post that quotes it, and recursively the quotes of those,
-- which is what keeps a quote card from ever pointing at content that is gone.
-- The trade-off is deliberate: ON DELETE SET NULL would leave the quotes
-- standing with an empty card, and would need a second column to tell "never
-- quoted anything" apart from "quoted something that was deleted".
--
-- Adding a nullable column is metadata-only in Postgres, so no table rewrite
-- happens here. The index is not concurrent because Prisma runs migrations
-- inside a transaction; if posts ever grows large enough for that to matter,
-- it should move to a separate manual step.

-- AlterTable
ALTER TABLE "public"."posts" ADD COLUMN "quoted_post_id" TEXT;

-- CreateIndex
CREATE INDEX "posts_quoted_post_id_idx" ON "public"."posts"("quoted_post_id");

-- AddForeignKey
ALTER TABLE "public"."posts" ADD CONSTRAINT "posts_quoted_post_id_fkey" FOREIGN KEY ("quoted_post_id") REFERENCES "public"."posts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
8 changes: 8 additions & 0 deletions prisma/models/post.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ model Post {
authorId String @map("author_id")
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)

// A quote post embeds another post, the way a quote tweet does. The relation
// is self-referential and cascades: deleting a post deletes every post that
// quotes it, so a quote card can never point at content that is gone.
quotedPostId String? @map("quoted_post_id")
quotedPost Post? @relation("PostQuotes", fields: [quotedPostId], references: [id], onDelete: Cascade)
quotes Post[] @relation("PostQuotes")

tags Tag[]
likes PostLike[]
bookmarks PostBookmark[]
Expand All @@ -38,6 +45,7 @@ model Post {
@@index([createdAt])
@@index([type])
@@index([category])
@@index([quotedPostId])
@@map("posts")
}

Expand Down
29 changes: 29 additions & 0 deletions src/core/domain/entities/post.entity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PostType } from "@core/domain/enums/post-type.enum";
import type { PostProps } from "@core/domain/interfaces/post-props.interface";
import type { PostCategory } from "../enums/post-category-enum";
import type { QuotedPostSnapshot } from "../interfaces/quoted-post.interface";

/**
* Rich domain model for Post entity
Expand All @@ -23,6 +24,7 @@ export class Post {
* @param authorId - The unique identifier of the post's author.
* @param mediaUrls - Optional. An array of media URLs associated with the post. Defaults to an empty array.
* @param categories - Optional. An array of categories assigned to the post. Defaults to an empty array.
* @param quotedPostId - Optional. The id of the post this one quotes.
* @returns A new Post instance with the specified properties.
*/
public static create(
Expand All @@ -31,6 +33,7 @@ export class Post {
authorId: string,
mediaUrls: string[] = [],
categories: PostCategory[] = [],
quotedPostId?: string,
): Post {
return new Post({
content,
Expand All @@ -39,6 +42,7 @@ export class Post {
author: { id: authorId },
tags: [],
categories,
quotedPostId,
});
}

Expand Down Expand Up @@ -155,6 +159,23 @@ export class Post {
return this.props.categories ?? [];
}

/**
* Get the id of the post this one quotes
* @returns The quoted post ID, or undefined when this is not a quote post
*/
get quotedPostId(): string | undefined {
return this.props.quotedPostId;
}

/**
* Get the embedded snapshot of the quoted post
* @returns The quoted post, or undefined when this post quotes nothing or
* was loaded without its quote relation
*/
get quotedPost(): QuotedPostSnapshot | undefined {
return this.props.quotedPost;
}

/**
* Check if the post has any media attached
* @returns True if the post has one or more media items
Expand All @@ -163,6 +184,14 @@ export class Post {
return this.props.mediaUrls.length > 0;
}

/**
* Check if this post quotes another post
* @returns True if the post was created as a quote of another post
*/
public isQuote(): boolean {
return this.props.quotedPostId !== undefined;
}

/**
* Check if the given user is the author of this post
* @param userId - The user ID to check
Expand Down
17 changes: 17 additions & 0 deletions src/core/domain/interfaces/post-props.interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { PostType } from "@core/domain/enums";
import type { PostCategory } from "../enums/post-category-enum";
import type { QuotedPostSnapshot } from "./quoted-post.interface";

/**
* Props interface for Post entity
Expand Down Expand Up @@ -69,4 +70,20 @@ export interface PostProps {

/** Array of categories associated with the post */
categories: PostCategory[];

/**
* The post this one quotes, when it is a quote post.
*
* Only ever the id on a post built by `Post.create`; a post read back from
* the database carries `quotedPost` alongside it.
*/
quotedPostId?: string;

/**
* The embedded snapshot of the quoted post.
*
* Present only when the post was loaded with its quote relation. A quote
* of a quote carries one level and no more - see {@link QuotedPostSnapshot}.
*/
quotedPost?: QuotedPostSnapshot;
}
37 changes: 37 additions & 0 deletions src/core/domain/interfaces/quoted-post.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* The snapshot of a post as it appears embedded inside a quote post.
*
* Deliberately narrower than {@link PostProps}: a quote card shows who wrote
* the quoted post, what it said and when, and nothing else. Keeping it a
* separate shape rather than nesting `PostProps` inside itself is what stops
* the embed from recursing - a quote of a quote carries only the post it
* quotes, never that post's own quote.
*/
export interface QuotedPostSnapshot {
/** The unique identifier of the quoted post */
id: string;

/** The content text of the quoted post */
content: string;

/** Media attached to the quoted post */
mediaUrls: string[];

/** When the quoted post was created */
createdAt: Date;

/** The author of the quoted post */
author: {
/** The unique identifier of the quoted post's author */
id: string;

/** Handle of the quoted post's author */
username: string;

/** Optional avatar URL of the author for display purposes */
avatarUrl?: string;

/** Optional display name of the author */
fullName?: string;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,12 @@ export interface CreatePostInput {
* Array of categories associated with the post for classification and discovery.
*/
categories?: PostCategory[];

/**
* Optional id of the post this one quotes.
*
* When set, the created post embeds that post as a quote card. Quoting a
* quote is allowed; only the read side stops at one level.
*/
quotedPostId?: string;
}
18 changes: 17 additions & 1 deletion src/core/use-cases/post/create-post/create-post.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,20 @@ export class CreatePostUseCase {
/**
* Executes the post creation process.
*
* @param input - Input containing post content, type, author ID, and media URLs
* @param input - Input containing post content, type, author ID, media URLs
* and the optional id of the post being quoted
* @returns Promise<void> - Resolves when post creation is complete
*
* @throws NotFoundError - When quotedPostId names a post that does not exist
*
* @remarks
* This method creates a new post entity, saves it to the database,
* and clears any cached feed data to ensure consistency.
*
* A quoted post is resolved before the write so a quote can never be
* stored against an id that is already gone. The foreign key would reject
* it too, but a 404 says what happened and a constraint violation does not.
*
* Followers are notified after the post is committed, deliberately
* outside the caller's critical path: the post is the thing worth keeping,
* so a fan-out failure is logged rather than allowed to fail the request.
Expand All @@ -57,12 +64,21 @@ export class CreatePostUseCase {
);
}
}

if (input.quotedPostId) {
const quoted = await this.postRepository.findById(
input.quotedPostId,
);
if (!quoted) throw new NotFoundError("Quoted post not found.");
}

const post = Post.create(
input.content,
input.type,
input.authorId,
input.mediaUrls || [],
input.categories || [],
input.quotedPostId,
);

const rawPost = await this.postRepository.create(post);
Expand Down
23 changes: 23 additions & 0 deletions src/core/use-cases/post/get-posts/get-posts.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { GetPostsOutput } from "./get-posts-usecase.output";
import { Post } from "@core/domain/entities/post.entity";
import { UnauthorizedError } from "@core/errors";
import { PostType } from "@core/domain/enums";
import type { QuotedPostSnapshot } from "@core/domain/interfaces/quoted-post.interface";

interface CachedPostData {
id: string;
Expand All @@ -20,6 +21,27 @@ interface CachedFeedData {
total: number;
}

/**
* Rebuilds the quoted post card that came back from the cache.
*
* `JSON.parse` leaves every date a string, and the caller revives only the
* top-level `createdAt` / `updatedAt`. Without this the same request would
* answer with a `Date` on a cache miss and a string for the next 60 seconds,
* so the serialised shape of the response would flip on its own.
*
* @param raw - The `quotedPost` value as it was parsed out of the cache
* @returns The card with a real `Date`, or undefined when nothing is quoted
*/
function hydrateQuotedPost(raw: unknown): QuotedPostSnapshot | undefined {
if (!raw) return undefined;

const quoted = raw as Omit<QuotedPostSnapshot, "createdAt"> & {
createdAt: string;
};

return { ...quoted, createdAt: new Date(quoted.createdAt) };
}

function shufflePosts(posts: Post[]): Post[] {
const shuffled = [...posts];
for (let i = shuffled.length - 1; i > 0; i--) {
Expand Down Expand Up @@ -69,6 +91,7 @@ export class GetPostsUseCase {
id: p.id || (data.id as string),
createdAt: new Date(data.createdAt as string),
updatedAt: new Date(data.updatedAt as string),
quotedPost: hydrateQuotedPost(data.quotedPost),
});
});

Expand Down
4 changes: 3 additions & 1 deletion src/http/controllers/post.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,16 @@ export class PostController {
reply: FastifyReply,
): Promise<void> {
const authorId = request.user.id;
const { content, type, mediaUrls, categories } = request.body;
const { content, type, mediaUrls, categories, quotedPostId } =
request.body;

const post = await this.createPostUseCase.execute({
authorId,
content,
type,
mediaUrls,
categories,
quotedPostId,
});

const cdnUrl = this.normalizeCdnUrl(
Expand Down
6 changes: 6 additions & 0 deletions src/http/types/schemas/post/create-post.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export const createPostBodySchema = Type.Object({
uniqueItems: true,
}),
),
quotedPostId: Type.Optional(
Type.String({
format: "uuid",
description: "The post this one quotes, rendered as a quote card",
}),
),
});

export type CreatePostBody = Static<typeof createPostBodySchema>;
Expand Down
17 changes: 17 additions & 0 deletions src/http/types/schemas/post/get-post.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ export const PostAuthorSchema = FBType.Object({
isMe: FBType.Optional(FBType.Boolean()),
});

/**
* The quoted post embedded in a quote post.
*
* One level only: a quote card carries no `quotedPost` of its own, and no
* counters or viewer-specific flags.
*/
export const QuotedPostSchema = FBType.Object({
id: FBType.String({ format: "uuid" }),
content: FBType.String(),
mediaUrls: FBType.Array(FBType.String()),
createdAt: FBType.String(),
author: PostAuthorSchema,
});

export type QuotedPost = Static<typeof QuotedPostSchema>;

export const PostItemSchema = FBType.Object({
id: FBType.String({ format: "uuid" }),
content: FBType.String(),
Expand All @@ -23,6 +39,7 @@ export const PostItemSchema = FBType.Object({
author: PostAuthorSchema,
tags: FBType.Array(FBType.Object({ name: FBType.String() })),
categories: FBType.Array(FBType.Object({ name: FBType.String() })),
quotedPost: FBType.Union([QuotedPostSchema, FBType.Null()]),
});

export type PostItem = Static<typeof PostItemSchema>;
Expand Down
Loading