Repository: TryGhost/ActivityPub
Pull request #382: Show repost and like count on user Profile
Author: login: vershwal
Created: 2025-03-11T06:07:23Z | Merged: 2025-03-13T14:11:32Z

Ref https://linear.app/ghost/issue/AP-864


Diff:
diff --git a/features/delete-post.feature b/features/delete-post.feature
index a11527654..03c4ada89 100644
--- a/features/delete-post.feature
+++ b/features/delete-post.feature
@@ -14,15 +14,15 @@ Feature: Delete a post
     And "OurNote" is in our Outbox
     And an authenticated request is made to "/.ghost/activitypub/feed"
     And the request is accepted
-    And the feed contains "OurNote"
-    And the feed contains "AliceNote"
+    And "OurNote" is in the feed
+    And "AliceNote" is in the feed
 
   Scenario: We can delete our post and it should remove it from the feed
     Given an authenticated "delete" request is made to "/.ghost/activitypub/post/OurNote"
     And the request is accepted with a 204
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed does not contain "OurNote"
+    And "OurNote" is not in the feed
     And "OurNote" is not in our Outbox
     And a "Delete(OurNote)" activity is sent to "Alice"
 
@@ -31,4 +31,4 @@ Feature: Delete a post
     And the request is rejected with a 403
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "AliceNote"
+    And "AliceNote" is in the feed
diff --git a/features/feed.feature b/features/feed.feature
index 2202d25ae..69e846271 100644
--- a/features/feed.feature
+++ b/features/feed.feature
@@ -15,8 +15,8 @@ Feature: Feed
     And "Article1" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "Note1"
-    And the feed does not contain "Article1"
+    And "Note1" is in the feed
+    And "Article1" is not in the feed
 
   Scenario: Feed is sorted by date descending
     Given a "Create(Note)" Activity "Note1" by "Alice"
@@ -27,8 +27,8 @@ Feature: Feed
     And "Note2" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And post "1" in the feed is "Note2"
-    And post "2" in the feed is "Note1"
+    And post "1" in the "feed" response is "Note2"
+    And post "2" in the "feed" response is "Note1"
 
   Scenario: Feed is paginated
     Given a "Create(Note)" Activity "Note1" by "Alice"
@@ -42,13 +42,13 @@ Feature: Feed
     And "Note3" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed?limit=2"
     Then the request is accepted
-    And the feed contains "Note3"
-    And the feed contains "Note2"
-    And the feed does not contain "Note1"
-    And the feed has a next cursor
+    And "Note3" is in the feed 
+    And "Note2" is in the feed
+    And "Note1" is not in the feed
+    And the feed response has a next cursor
     When an authenticated request is made to "/.ghost/activitypub/feed?limit=3"
     Then the request is accepted
-    And the feed contains "Note1"
+    And "Note1" is in the feed
 
   Scenario: Requests with limit over 100 are rejected
     When an authenticated request is made to "/.ghost/activitypub/feed?limit=200"
@@ -61,7 +61,7 @@ Feature: Feed
       """
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "Note1"
+    And "Note1" is in the feed
 
   Scenario: Feed includes posts we reposted
     Given a "Create(Note)" Activity "Note1" by "Alice"
@@ -71,7 +71,7 @@ Feature: Feed
     And the request is accepted
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "Note1"
+    And "Note1" is in the feed
 
   Scenario: Feed includes posts from followed accounts
     Given a "Create(Note)" Activity "Note1" by "Alice"
@@ -79,7 +79,7 @@ Feature: Feed
     And "Note1" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "Note1"
+    And "Note1" is in the feed
 
   Scenario: Feed includes reposts from followed accounts
     Given an Actor "Person(Bob)"
@@ -89,7 +89,7 @@ Feature: Feed
     And "Repost1" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "Note1"
+    And "Note1" is in the feed
 
   Scenario: Feed excludes replies
     Given a "Create(Note)" Activity "Note1" by "Alice"
@@ -102,8 +102,8 @@ Feature: Feed
     And "ReplyCreate" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "Note1"
-    And the feed does not contain "ReplyCreate"
+    And "Note1" is in the feed
+    And "ReplyCreate" is not in the feed
 
   Scenario: Querying the inbox (feed filtered to only return articles)
     Given a "Create(Article)" Activity "Article1" by "Alice"
@@ -117,9 +117,9 @@ Feature: Feed
     And "Note1" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/inbox"
     Then the request is accepted
-    And post "1" in the feed is "Article2"
-    And post "2" in the feed is "Article1"
-    And the feed does not contain "Note1"
+    And post "1" in the "feed" response is "Article2"
+    And post "2" in the "feed" response is "Article1"
+    And "Note1" is not in the feed
 
   Scenario: Feed is sanitised
     Given a "Create(Note)" Activity "Note" by "Alice" with content "Hello, world!<script>alert('boo')</script>"
@@ -127,5 +127,5 @@ Feature: Feed
     And "Note" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "Note"
+    And "Note" is in the feed
     And the "Note" in the feed has content "Hello, world!"
diff --git a/features/handle-delete.feature b/features/handle-delete.feature
index 86526226c..0dedf74e5 100644
--- a/features/handle-delete.feature
+++ b/features/handle-delete.feature
@@ -20,18 +20,18 @@ Feature: Delete(Note)
     And "AliceNote" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "AliceNote"
-    And the feed does not contain "AliceNote"
+    And "AliceNote" is in the feed
+    And "AliceNote" is not in the feed
 
   Scenario: We recieve a Delete(Note) activity from someone we follow
     Given a "Delete(AliceNote)" Activity "DeleteNote" by "Alice"
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed does not contain "AliceNote"
+    And "AliceNote" is not in the feed
 
   Scenario: We recieve a Delete(Note) activity from someone who didn't create the post
     Given a "Delete(AliceNote)" Activity "DeleteNote" by "Bob"
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed does not contain "AliceNote"
+    And "AliceNote" is not in the feed
 
diff --git a/features/profile-liked-post.feature b/features/profile-liked-post.feature
new file mode 100644
index 000000000..9688d1054
--- /dev/null
+++ b/features/profile-liked-post.feature
@@ -0,0 +1,72 @@
+Feature: Liked Posts on Profile
+  In order to see posts that I have liked
+  As a user
+  I want to query my liked posts
+
+  Background:
+    Given we are following "Alice"
+
+  Scenario: Querying the liked posts
+    Given a "Create(Note)" Activity "Note1" by "Alice"
+    And "Alice" sends "Note1" to the Inbox
+    And "Note1" is in our Inbox
+    And a "Create(Note)" Activity "Note2" by "Alice"
+    And "Alice" sends "Note2" to the Inbox
+    And "Note2" is in our Inbox
+    And we like the object "Note1"
+    And the request is accepted
+    When an authenticated request is made to "/.ghost/activitypub/posts/liked"
+    Then the request is accepted
+    And "Note1" is in the liked posts
+    And "Note2" is not in the liked posts
+
+  Scenario: Liked posts are sorted by date descending
+    Given a "Create(Note)" Activity "Note1" by "Alice"
+    And "Alice" sends "Note1" to the Inbox
+    And "Note1" is in our Inbox
+    And a "Create(Note)" Activity "Note2" by "Alice"
+    And "Alice" sends "Note2" to the Inbox
+    And "Note2" is in our Inbox
+    And we like the object "Note1"
+    And we like the object "Note2"
+    When an authenticated request is made to "/.ghost/activitypub/posts/liked"
+    Then the request is accepted
+    And post "1" in the "liked posts" response is "Note2"
+    And post "2" in the "liked posts" response is "Note1"
+
+  Scenario: Liked posts are paginated
+    Given a "Create(Note)" Activity "Note1" by "Alice"
+    And "Alice" sends "Note1" to the Inbox
+    And "Note1" is in our Inbox
+    And a "Create(Note)" Activity "Note2" by "Alice"
+    And "Alice" sends "Note2" to the Inbox
+    And "Note2" is in our Inbox
+    And a "Create(Note)" Activity "Note3" by "Alice"
+    And "Alice" sends "Note3" to the Inbox
+    And "Note3" is in our Inbox
+    And we like the object "Note1"
+    And we like the object "Note2"
+    And we like the object "Note3"
+    When an authenticated request is made to "/.ghost/activitypub/posts/liked?limit=2"
+    Then the request is accepted
+    And "Note3" is in the liked posts
+    And "Note2" is in the liked posts
+    And "Note1" is not in the liked posts
+    And the liked posts response has a next cursor
+    When an authenticated request is made to "/.ghost/activitypub/posts/liked?limit=3"
+    Then the request is accepted
+    And "Note1" is in the liked posts
+
+  Scenario: Requests with limit over 100 are rejected
+    When an authenticated request is made to "/.ghost/activitypub/posts/liked?limit=200"
+    Then the request is rejected with a 400
+
+  Scenario: Liked posts include our own posts
+    Given we create a note "Note1" with the content
+      """
+      Hello World
+      """
+    And we like the object "Note1"
+    When an authenticated request is made to "/.ghost/activitypub/posts/liked"
+    Then the request is accepted
+    And "Note1" is in the liked posts
diff --git a/features/profile-posts.feature b/features/profile-posts.feature
new file mode 100644
index 000000000..38ac60de2
--- /dev/null
+++ b/features/profile-posts.feature
@@ -0,0 +1,65 @@
+Feature: My Posts on Profile
+  In order to see my posts on my profile
+  As a user
+  I want to query my posts
+
+  Background:
+    Given we create a note "MyNote" with the content
+      """
+      Hello World 1
+      """
+    And We waited for 1000 milliseconds
+    And we create a note "MyNote2" with the content
+      """
+      Hello World 2
+      """
+    And We waited for 1000 milliseconds
+    And we are following "Alice"
+
+  Scenario: Querying profile posts
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "MyNote" is in the posts
+    And "MyNote2" is in the posts
+
+  Scenario: My posts does not contain posts from followed accounts
+    Given a "Create(Note)" Activity "AliceNote" by "Alice"
+    And "Alice" sends "AliceNote" to the Inbox
+    And "AliceNote" is in our Inbox
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "MyNote" is in the posts
+    And "MyNote2" is in the posts
+    And "AliceNote" is not in the posts
+
+  Scenario: Profile posts includes posts we reposted
+    Given a "Create(Note)" Activity "AliceNote" by "Alice"
+    And "Alice" sends "AliceNote" to the Inbox
+    And "AliceNote" is in our Inbox
+    And we repost the object "AliceNote"
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "AliceNote" is in the posts
+  
+  Scenario: Liked posts are sorted by date descending
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And post "1" in the "posts" response is "MyNote2"
+    And post "2" in the "posts" response is "MyNote"
+
+  Scenario: Liked posts are paginated
+    Given we create a note "MyNote3" with the content
+      """
+      Hello World 3
+      """
+    When an authenticated request is made to "/.ghost/activitypub/posts?limit=2"
+    Then the request is accepted
+    And "MyNote3" is in the posts
+    And "MyNote2" is in the posts
+    And "MyNote" is not in the posts
+    And the posts response has a next cursor
+    When an authenticated request is made to "/.ghost/activitypub/posts?limit=3"
+    Then the request is accepted
+    And "MyNote" is in the posts
+    And "MyNote2" is in the posts
+    And "MyNote3" is in the posts
diff --git a/features/step_definitions/stepdefs.js b/features/step_definitions/stepdefs.js
index 6bb1bc9b5..71e668f9f 100644
--- a/features/step_definitions/stepdefs.js
+++ b/features/step_definitions/stepdefs.js
@@ -1702,6 +1702,10 @@ When('we attempt to create a note with invalid content', async function () {
     );
 });
 
+When('We waited for {int} milliseconds', async (milliseconds) => {
+    await new Promise((resolve) => setTimeout(resolve, milliseconds));
+});
+
 When(
     'we create a note {string} with the content',
     async function (noteName, noteContent) {
@@ -1836,22 +1840,28 @@ When('we request the feed with the next cursor', async function () {
     );
 });
 
-Then('the feed contains {string}', async function (activityOrObjectName) {
-    const responseJson = await this.response.clone().json();
-    const activity = this.activities[activityOrObjectName];
-    const object = this.objects[activityOrObjectName];
-    let found;
+Then(
+    /"([^"]*)" is in the (posts|feed|liked posts)/,
+    async function (activityOrObjectName, responseType) {
+        const responseJson = await this.response.clone().json();
+        const activity = this.activities[activityOrObjectName];
+        const object = this.objects[activityOrObjectName];
+        let found;
 
-    if (activity) {
-        found = responseJson.posts.find(
-            (post) => post.url === activity.object.id,
-        );
-    } else if (object) {
-        found = responseJson.posts.find((post) => post.url === object.id);
-    }
+        if (activity) {
+            found = responseJson.posts.find(
+                (post) => post.url === activity.object.id,
+            );
+        } else if (object) {
+            found = responseJson.posts.find((post) => post.url === object.id);
+        }
 
-    assert(found, `Expected to find ${activityOrObjectName} in feed`);
-});
+        assert(
+            found,
+            `Expected to find ${activityOrObjectName} in ${responseType}`,
+        );
+    },
+);
 
 Then(
     'the {string} in the feed has content {string}',
@@ -1874,8 +1884,8 @@ Then(
 );
 
 Then(
-    'the feed does not contain {string}',
-    async function (activityOrObjectName) {
+    /"([^"]*)" is not in the (posts|feed|liked posts)/,
+    async function (activityOrObjectName, responseType) {
         const responseJson = await this.response.clone().json();
         const activity = this.activities[activityOrObjectName];
         const object = this.objects[activityOrObjectName];
@@ -1887,19 +1897,28 @@ Then(
             found = responseJson.posts.find((post) => post.url === object.id);
         }
 
-        assert(!found, `Expected not to find ${activityOrObjectName} in feed`);
+        assert(
+            !found,
+            `Expected not to find ${activityOrObjectName} in ${responseType}`,
+        );
     },
 );
 
-Then('the feed has a next cursor', async function () {
-    const responseJson = await this.response.clone().json();
+Then(
+    /the (posts|feed|liked posts) response has a next cursor/,
+    async function (type) {
+        const responseJson = await this.response.clone().json();
 
-    assert(responseJson.next, 'Expected feed to have a next cursor');
-});
+        assert(
+            responseJson.next,
+            `Expected ${type} response to have a next cursor`,
+        );
+    },
+);
 
 Then(
-    'post {string} in the feed is {string}',
-    async function (postNumber, activityOrObjectName) {
+    'post {string} in the {string} response is {string}',
+    async function (postNumber, type, activityOrObjectName) {
         const responseJson = await this.response.clone().json();
         const activity = this.activities[activityOrObjectName];
         const object = this.objects[activityOrObjectName];
diff --git a/src/account/account.service.ts b/src/account/account.service.ts
index 8b0caf472..a85061342 100644
--- a/src/account/account.service.ts
+++ b/src/account/account.service.ts
@@ -16,6 +16,9 @@ import {
     AP_BASE_PATH,
     TABLE_ACCOUNTS,
     TABLE_FOLLOWS,
+    TABLE_LIKES,
+    TABLE_POSTS,
+    TABLE_REPOSTS,
     TABLE_USERS,
 } from '../constants';
 import type { Account } from './account.entity';
@@ -290,6 +293,38 @@ export class AccountService {
         return Number(result[0].count);
     }
 
+    /**
+     * Get the number of posts liked by the account
+     *
+     * @param account Account
+     */
+    async getLikedCount(account: AccountType): Promise<number> {
+        const result = await this.db(TABLE_LIKES)
+            .join('posts', 'likes.post_id', 'posts.id')
+            .where('likes.account_id', account.id)
+            .whereNull('posts.in_reply_to')
+            .count('*', { as: 'count' });
+
+        return Number(result[0].count);
+    }
+
+    /**
+     * Get the number of posts created by the account
+     *
+     * @param account Account
+     */
+    async getPostCount(account: AccountType): Promise<number> {
+        const posts = await this.db(TABLE_POSTS)
+            .where('author_id', account.id)
+            .count('*', { as: 'count' });
+
+        const reposts = await this.db(TABLE_REPOSTS)
+            .where('account_id', account.id)
+            .count('*', { as: 'count' });
+
+        return Number(posts[0].count) + Number(reposts[0].count);
+    }
+
     /**
      * Get the accounts that are following the provided account
      *
diff --git a/src/app.ts b/src/app.ts
index 2d66386cd..0b4df2be5 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -99,6 +99,8 @@ import {
     createDeletePostHandler,
     createGetAccountFollowsHandler,
     createGetAccountHandler,
+    createGetAccountLikedPostsHandler,
+    createGetAccountPostsHandler,
     createGetFeedHandler,
     createGetProfileFollowersHandler,
     createGetProfileFollowingHandler,
@@ -255,6 +257,7 @@ const accountService = new AccountService(
 const postService = new PostService(
     postRepository,
     accountService,
+    client,
     fedifyContextFactory,
 );
 const siteService = new SiteService(client, accountService, {
@@ -963,6 +966,16 @@ app.get(
     requireRole(GhostRole.Owner),
     spanWrapper(createGetAccountHandler(accountService)),
 );
+app.get(
+    '/.ghost/activitypub/posts',
+    requireRole(GhostRole.Owner),
+    spanWrapper(createGetAccountPostsHandler(accountService, postService)),
+);
+app.get(
+    '/.ghost/activitypub/posts/liked',
+    requireRole(GhostRole.Owner),
+    spanWrapper(createGetAccountLikedPostsHandler(accountService, postService)),
+);
 app.get(
     '/.ghost/activitypub/account/:handle/follows/:type',
     requireRole(GhostRole.Owner),
diff --git a/src/http/api/account.ts b/src/http/api/account.ts
index 937f84472..7118a85c6 100644
--- a/src/http/api/account.ts
+++ b/src/http/api/account.ts
@@ -1,9 +1,8 @@
-import type { KvStore } from '@fedify/fedify';
-
-import type { AccountService } from '../../account/account.service';
-import { getAccountHandle } from '../../account/utils';
-import type { AppContext } from '../../app';
-import { sanitizeHtml } from '../../helpers/html';
+import type { AccountService } from 'account/account.service';
+import { getAccountHandle } from 'account/utils';
+import type { AppContext } from 'app';
+import { sanitizeHtml } from 'helpers/html';
+import type { PostService } from 'post/post.service';
 import type { AccountDTO } from './types';
 
 /**
@@ -12,34 +11,22 @@ import type { AccountDTO } from './types';
 const FOLLOWS_LIMIT = 20;
 
 /**
- * Follow account shape - Used when returning a list of follow accounts
+ * Default number of posts to return in a profile
  */
-type FollowAccount = Pick<
-    AccountDTO,
-    'id' | 'name' | 'handle' | 'avatarUrl'
-> & { isFollowing: boolean };
+const DEFAULT_POSTS_LIMIT = 20;
 
 /**
- * Retreive the count of posts created by the account from the database
- *
- * @param db Database instance
+ * Maximum number of posts that can be returned in a profile
  */
-async function getPostCount(db: KvStore) {
-    const posts = await db.get<string[]>(['outbox']);
-
-    return posts?.length || 0;
-}
+const MAX_POSTS_LIMIT = 100;
 
 /**
- * Retreive the count of posts liked by the account from the database
- *
- * @param db Database instance
+ * Follow account shape - Used when returning a list of follow accounts
  */
-async function getLikedCount(db: KvStore) {
-    const liked = await db.get<string[]>(['liked']);
-
-    return liked?.length || 0;
-}
+type FollowAccount = Pick<
+    AccountDTO,
+    'id' | 'name' | 'handle' | 'avatarUrl'
+> & { isFollowing: boolean };
 
 /**
  * Create a handler to handle a request for an account
@@ -92,8 +79,8 @@ export function createGetAccountHandler(accountService: AccountService) {
                  * At the moment we don't support custom fields for Ghost accounts
                  */
                 customFields: {},
-                postCount: await getPostCount(db),
-                likedCount: await getLikedCount(db),
+                postCount: await accountService.getPostCount(account),
+                likedCount: await accountService.getLikedCount(account),
                 followingCount:
                     await accountService.getFollowingAccountsCount(account),
                 followerCount:
@@ -217,3 +204,105 @@ export function createGetAccountFollowsHandler(accountService: AccountService) {
         );
     };
 }
+
+/**
+ * Validates and extracts pagination parameters from the request
+ *
+ * @param ctx App context
+ * @returns Object containing cursor and limit, or null if invalid
+ */
+function validateRequestParams(ctx: AppContext) {
+    const queryCursor = ctx.req.query('next');
+    const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;
+
+    const queryLimit = ctx.req.query('limit');
+    const limit = queryLimit ? Number(queryLimit) : DEFAULT_POSTS_LIMIT;
+
+    if (limit > MAX_POSTS_LIMIT) {
+        return null;
+    }
+
+    return { cursor, limit };
+}
+
+/**
+ * Create a handler to handle a request for a list of posts by an account
+ *
+ * @param accountService Account service instance
+ * @param profileService Profile service instance
+ */
+export function createGetAccountPostsHandler(
+    accountService: AccountService,
+    postService: PostService,
+) {
+    /**
+     * Handle a request for a list of posts by an account
+     *
+     * @param ctx App context
+     */
+    return async function handleGetPosts(ctx: AppContext) {
+        const params = validateRequestParams(ctx);
+        if (!params) {
+            return new Response(null, { status: 400 });
+        }
+
+        const account = await accountService.getDefaultAccountForSite(
+            ctx.get('site'),
+        );
+        const { results, nextCursor } = await postService.getPostsByAccount(
+            account.id,
+            params.limit,
+            params.cursor,
+        );
+
+        return new Response(
+            JSON.stringify({
+                posts: results,
+                next: nextCursor,
+            }),
+            { status: 200 },
+        );
+    };
+}
+
+/**
+ * Create a handler to handle a request for a list of posts liked by an account
+ *
+ * @param accountService Account service instance
+ * @param profileService Profile service instance
+ */
+export function createGetAccountLikedPostsHandler(
+    accountService: AccountService,
+    postService: PostService,
+) {
+    /**
+     * Handle a request for a list of posts liked by an account
+     *
+     * @param ctx App context
+     */
+    return async function handleGetLikedPosts(ctx: AppContext) {
+        const params = validateRequestParams(ctx);
+        if (!params) {
+            return new Response(null, { status: 400 });
+        }
+
+        const account = await accountService.getDefaultAccountForSite(
+            ctx.get('site'),
+        );
+
+        const { results, nextCursor } =
+            await postService.getPostsLikedByAccount(
+                account.id,
+                params.limit,
+                params.cursor,
+            );
+
+        return new Response(
+            JSON.stringify({
+                posts: results,
+                next: nextCursor,
+            }),
+            { status: 200 },
+        );
+    };
+}
diff --git a/src/http/api/profile.ts b/src/http/api/profile.ts
index c94453be9..5a8615da2 100644
--- a/src/http/api/profile.ts
+++ b/src/http/api/profile.ts
@@ -1,12 +1,16 @@
+import { createHash } from 'node:crypto';
 import {
     Activity,
     type Actor,
+    Announce,
     type CollectionPage,
+    Like,
     isActor,
 } from '@fedify/fedify';
 
-import type { AccountService } from '../../account/account.service';
-import { type AppContext, fedify } from '../../app';
+import type { AccountService } from 'account/account.service';
+import { type AppContext, fedify } from 'app';
+import { getActivityChildrenCount, getRepostCount } from 'db';
 import {
     getAttachments,
     getFollowerCount,
@@ -14,10 +18,10 @@ import {
     getHandle,
     isFollowedByDefaultSiteAccount,
     isHandle,
-} from '../../helpers/activitypub/actor';
-import { sanitizeHtml } from '../../helpers/html';
-import { isUri } from '../../helpers/uri';
-import { lookupObject } from '../../lookup-helpers';
+} from 'helpers/activitypub/actor';
+import { sanitizeHtml } from 'helpers/html';
+import { isUri } from 'helpers/uri';
+import { lookupObject } from 'lookup-helpers';
 
 interface Profile {
     actor: any;
@@ -239,6 +243,30 @@ export function createGetProfilePostsHandler(accountService: AccountService) {
                 activity.object.authored =
                     defaultSiteAccount.ap_id === activity.actor.id;
 
+                // Add reply count and repost count to the object
+                activity.object.replyCount =
+                    await getActivityChildrenCount(activity);
+                activity.object.repostCount = await getRepostCount(activity);
+
+                // Check if the activity is liked or reposted by default site account
+                const objectId = activity.object.id;
+                if (objectId) {
+                    const likeId = apCtx.getObjectUri(Like, {
+                        id: createHash('sha256').update(objectId).digest('hex'),
+                    });
+                    const repostId = apCtx.getObjectUri(Announce, {
+                        id: createHash('sha256').update(objectId).digest('hex'),
+                    });
+
+                    const liked = (await db.get<string[]>(['liked'])) || [];
+
+                    const reposted =
+                        (await db.get<string[]>(['reposted'])) || [];
+
+                    activity.object.liked = liked.includes(likeId.href);
+                    activity.object.reposted = reposted.includes(repostId.href);
+                }
+
                 if (typeof activity.actor === 'string') {
                     activity.actor = await actor.toJsonLd({
                         format: 'compact',
diff --git a/src/post/post.service.ts b/src/post/post.service.ts
index 0c95aeb44..f6ebab56c 100644
--- a/src/post/post.service.ts
+++ b/src/post/post.service.ts
@@ -1,16 +1,136 @@
 import { Article, Note, lookupObject } from '@fedify/fedify';
-import type { AccountService } from '../account/account.service';
-import type { FedifyContextFactory } from '../activitypub/fedify-context.factory';
+import type { AccountService } from 'account/account.service';
+import { getAccountHandle } from 'account/utils';
+import type { FedifyContextFactory } from 'activitypub/fedify-context.factory';
+import type { PostDTO } from 'http/api/types';
+import type { Knex } from 'knex';
 import { Post, type PostAttachment, PostType } from './post.entity';
 import type { KnexPostRepository } from './post.repository.knex';
 
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
 export class PostService {
     constructor(
         private readonly postRepository: KnexPostRepository,
         private readonly accountService: AccountService,
+        private readonly db: Knex,
         private readonly fedifyContextFactory: FedifyContextFactory,
     ) {}
 
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
     /**
      * Get the attachments for a post
      *
@@ -105,4 +225,262 @@ export class PostService {
 
         return newlyCreatedPost;
     }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                            WHEN likes.post_id IS NOT NULL THEN 1 
+                            ELSE 0 
+                        END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                            WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                            ELSE 0 
+                        END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+                'posts_with_source.deleted_at',
+                'posts_with_source.in_reply_to',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        'posts.deleted_at',
+                        'posts.in_reply_to',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'posts.deleted_at',
+                                'posts.in_reply_to',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .where('posts_with_source.deleted_at', null)
+            .where('posts_with_source.in_reply_to', null)
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
+
+    /**
+     * Get posts liked by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsLikedByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db('likes')
+            .select(
+                'likes.id as likes_id',
+                // Post fields
+                'posts.id as post_id',
+                'posts.type as post_type',
+                'posts.title as post_title',
+                'posts.excerpt as post_excerpt',
+                'posts.content as post_content',
+                'posts.url as post_url',
+                'posts.image_url as post_image_url',
+                'posts.published_at as post_published_at',
+                'posts.like_count as post_like_count',
+                this.db.raw('1 AS post_liked_by_user'), // Since we are selecting from `likes`, this is always 1
+                'posts.reply_count as post_reply_count',
+                'posts.reading_time_minutes as post_reading_time_minutes',
+                'posts.attachments as post_attachments',
+                'posts.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE
+                            WHEN reposts.post_id IS NOT NULL THEN 1
+                            ELSE 0
+                        END AS post_reposted_by_user`,
+                ),
+                'posts.ap_id as post_ap_id',
+                // Author fields
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+            )
+            .innerJoin('posts', 'posts.id', 'likes.post_id')
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts.author_id',
+            )
+            .leftJoin('reposts', function () {
+                this.on('reposts.post_id', 'posts.id').andOnVal(
+                    'reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'reposts.account_id',
+            )
+            .where('likes.account_id', accountId)
+            .modify((query) => {
+                if (cursor) {
+                    query.where('likes.id', '<', cursor);
+                }
+            })
+            .where('posts.in_reply_to', null)
+            .orderBy('likes.id', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.likes_id.toString() : null,
+        };
+    }
 }


Reviews:
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T13:33:10Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T13:35:30Z
user: login: mike182uk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T18:02:54Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T18:09:03Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T18:16:23Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T18:17:15Z
user: login: allouis
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T04:12:54Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T05:36:54Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T05:37:34Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T05:44:06Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T05:52:41Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T06:06:43Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T06:16:39Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T08:06:57Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T08:56:24Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T08:58:52Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>features/profile-posts.feature (2)</summary><blockquote>

`44-49`: **Remove @only tag before merging.**

The @only tag should be removed before merging as it will prevent other scenarios from running during the test suite execution.

```diff
-  @only
   Scenario: Liked posts are sorted by date descending
```

---

`51-67`: **Remove @only tag and fix scenario name for consistency.**

The scenario name refers to "Liked posts" but it's testing profile posts pagination. Also, the @only tag should be removed before merging.

```diff
-  @only
-  Scenario: Liked posts are paginated
+  Scenario: Profile posts are paginated
```

</blockquote></details>
<details>
<summary>src/http/api/account.ts (2)</summary><blockquote>

`207-225`: **Validate and clamp pagination parameters more robustly.**  
`validateRequestParams` checks for limits above `MAX_PROFILE_POSTS_LIMIT`, but does not handle negative or non-numeric inputs. You could also clamp the limit to a minimum (e.g., 1) and handle parse failures.  

```diff
 function validateRequestParams(ctx: AppContext) {
     const queryCursor = ctx.req.query('next');
     const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;

     const queryLimit = ctx.req.query('limit');
     const limitNum = Number(queryLimit);

-    const limit = queryLimit ? limitNum : DEFAULT_PROFILE_POSTS_LIMIT;
+    let limit = DEFAULT_PROFILE_POSTS_LIMIT;
+    if (queryLimit && !Number.isNaN(limitNum) && limitNum > 0) {
+        limit = limitNum;
+    }

     if (limit > MAX_PROFILE_POSTS_LIMIT) {
         return null;
     }

     return { cursor, limit };
 }
```

---

`267-307`: **Consistent approach to liked posts retrieval.**  
`createGetAccountLikedPostsHandler` follows a similar pattern to retrieving posts but does not check for a missing `account` scenario. Consider mirroring the null check logic from the previous handler and responding gracefully if no default account is found.

</blockquote></details>
<details>
<summary>src/post/post.service.ts (2)</summary><blockquote>

`73-132`: **`mapToPostDTO` is clearly mapped but watch for URL parsing edge cases.**  
`mapToPostDTO` effectively translates DB rows to DTOs, including the repost logic. However, if `author_url` or `reposter_url` is malformed, `new URL(...)` could throw. Consider additional guards or error handling if user-provided data is untrusted.

---

`391-480`: **Liked posts retrieval logic is consistent & thorough.**  
`getPostsLikedByAccount` shares a similar approach to queries, but also depends on an index for `likes.id` descending. Confirm index usage on the `likes` table to avoid full scans for large user sets.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 46341287e46eab57d343b2984e72d17ee43a1abe and 6fba114b287c9831f2255ffc66576e4df8e97679.

</details>

<details>
<summary>📒 Files selected for processing (11)</summary>

* `features/delete-post.feature` (2 hunks)
* `features/feed.feature` (8 hunks)
* `features/handle-delete.feature` (1 hunks)
* `features/profile-liked-post.feature` (1 hunks)
* `features/profile-posts.feature` (1 hunks)
* `features/step_definitions/stepdefs.js` (4 hunks)
* `src/account/account.service.ts` (2 hunks)
* `src/app.ts` (3 hunks)
* `src/http/api/account.ts` (4 hunks)
* `src/http/api/profile.ts` (2 hunks)
* `src/post/post.service.ts` (2 hunks)

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: Build, Test and Deploy

</details>

<details>
<summary>🔇 Additional comments (36)</summary><blockquote>

<details>
<summary>features/feed.feature (1)</summary>

`18-19`: **Improved assertion language consistency**

The changes standardize assertion language across the test suite, making tests more readable and maintainable.



Also applies to: 30-31, 45-48, 51-51, 64-64, 74-74, 82-82, 92-92, 105-106, 120-122, 130-130

</details>
<details>
<summary>features/delete-post.feature (1)</summary>

`17-18`: **Improved test assertion format**

The changes standardize the format of assertions about feed contents, as requested in previous review comments.




Also applies to: 25-25, 34-34

</details>
<details>
<summary>src/app.ts (3)</summary>

`102-103`: **Added handlers for account posts functionality**

New handlers for retrieving account posts and liked posts will enhance user profiles with engagement metrics, supporting the PR's objective.

---

`251-251`: **Added client parameter to PostService constructor**

Database client was added to the PostService constructor, enabling direct database operations for the new post retrieval functionality.

---

`960-969`: **Added new routes for posts and liked posts**

These routes provide the API endpoints necessary for showing post and like counts on user profiles, properly secured with role-based access control.

</details>
<details>
<summary>src/http/api/profile.ts (3)</summary>

`11-13`: **Update imports to use absolute paths consistently.**

The imports are updated to use absolute paths which is good for consistency. This aligns with the team's agreement to use absolute imports as noted in a previous review comment.

---

`246-249`: **Adding reply and repost counts enhances the user experience.**

These additions allow the frontend to display the reply and repost counts on user profiles, which is the main objective of this PR. The implementation is clean and straightforward.

---

`251-268`: <details>
<summary>❓ Verification inconclusive</summary>

**Good implementation for liked/reposted status checks.**

This implementation correctly checks if the activity is liked or reposted by the default site account by:
1. Creating hash-based IDs for likes and reposts
2. Querying the database for these IDs
3. Setting boolean flags based on the presence of the IDs



Consider whether these functions should be moved to a different file as suggested in a previous review comment:

---

🌐 Web query:

```web
What are best practices for organizing API handlers in Node.js applications?
```

💡 Result:

Organizing API handlers in Node.js applications effectively requires a structured approach that promotes maintainability, scalability, and clear separation of concerns. Here are key best practices based on industry standards and expert recommendations:

---

### **1. Adopt a Layered Architecture**
Separate your application into distinct layers to isolate responsibilities:
- **Web Layer**: Handles HTTP requests/responses, routing, and input validation ([1][4][5][8]).
  ```markdown
  - Routes: Define endpoints and map them to controllers (e.g., `routes/products.js`).
  - Controllers: Extract data from requests, dispatch to services, and format responses (**no business logic**) [1][5][10].
  - Middleware: Reusable functions for tasks like authentication, logging, or caching [2][5][6].
  ```
- **Service Layer**: Contains core business logic and interacts with the data layer [1][10].
- **Data Access Layer**: Manages database operations via ORMs (e.g., Mongoose, TypeORM) or raw queries [1][2][8].

---

### **2. Use Component-Based Organization**
Group related functionality (e.g., `users`, `products`) into self-contained modules:
```
src/
├── api/
│   └── components/
│       └── user/
│           ├── controller.js
│           ├── routes.js
│           ├── service.js
│           └── model.js
└── middleware/
    ├── auth.js
    └── validation.js
```
- Each component includes its own routes, controllers, services, and models [2][8][10].
- Promotes reusability and simplifies testing/maintenance [1][2][9].

---

### **3. Modularize Routes with Express Router**
- Avoid defining all routes in a single file. Use `express.Router()` for modular route definitions:
  ```javascript
  // routes/users.js
  const router = require('express').Router();
  const { getUsers } = require('../controllers/users');

  router.get('/', getUsers);
  module.exports = router;
  ```
- Mount routes in a central file (e.g., `src/api/routes.js`) with versioning support [5][8]:
  ```javascript
  app.use('/api/v1/users', require('./components/user/routes'));
  ```

---

### **4. Decouple Middleware and Controllers**
- **Middleware**: Handle cross-cutting concerns like authentication and input validation:
  ```javascript
  // middleware/auth.js
  const authenticate = (req, res, next) => {
    if (req.user) next();
    else res.status(401).json({ error: 'Unauthorized' });
  };
  ```
- **Controllers**: Keep lightweight by delegating business logic to services [1][10]:
  ```javascript
  // controllers/users.js
  const UserService = require('../services/users');

  const getUsers = async (req, res) => {
    try {
      const users = await UserService.getAll();
      res.json(users);
    } catch (error) {
      res.status(500).json({ error: 'Internal server error' });
    }
  };
  ```

---

### **5. Implement Centralized Error Handling**
- Use middleware to catch and format errors consistently [4][6]:
  ```javascript
  app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).json({ error: 'Internal server error' });
  });
  ```
- Avoid exposing sensitive details (e.g., stack traces) in production [6].

---

### **6. Structure for Scalability**
- **Separate app and server**: Keep Express configuration (`app.js`) and server startup (`server.js`) in distinct files [1][8].
- **Environment configurations**: Use tools like `node-config` to manage environment variables and settings [7].
- **API documentation**: Generate OpenAPI/Swagger docs from code (e.g., `express-openapi`) to ensure accuracy [7].

---

### Example Project Structure
```
project/
├── src/
│   ├── config/       # Environment configurations
│   ├── api/
│   │   ├── components/
│   │   │   └── user/
│   │   │       ├── controller.js
│   │   │       ├── routes.js
│   │   │       └── service.js
│   │   ├── middleware/
│   │   └── routes.js # Central route registration
│   ├── models/       # Database models
│   └── server.js     # Server initialization
└── app.js            # Express app configuration
```

---

By following these practices, you ensure a clean separation of concerns, simplify testing, and make the codebase easier to navigate. Component-based organization and layered architectures are particularly recommended for large-scale applications, while smaller projects benefit from modular routes and middleware[1][2][5][8][10].

Citations:

- 1: https://blog.treblle.com/egergr/
- 2: https://hackernoon.com/how-i-organized-my-nodejs-rest-api-aq573376
- 3: https://docs.solace.com/API/API-Developer-Guide/JavaScript-Nodejs-APIs-Best-Practices.htm
- 4: https://blog.risingstack.com/10-best-practices-for-writing-node-js-rest-apis/
- 5: https://semaphore.io/blog/layer-nodejs
- 6: https://www.stackhawk.com/blog/nodejs-api-security-best-practices/
- 7: https://chadxz.dev/2021-node-api-best-practices/
- 8: https://www.petergirnus.com/blog/expressjs-organizing-routes-and-routing-for-an-api
- 9: https://github.com/goldbergyoni/nodebestpractices
- 10: https://www.coreycleary.me/project-structure-for-an-express-rest-api-when-there-is-no-standard-way

---



</details>

**Good implementation for determining liked/reposted status.**

Your logic for generating hash-based IDs and checking the corresponding entries in the database is solid. In line with best practices for organizing API handlers in Node.js, consider whether this business logic—specifically, the hash creation and database querying—might be better housed in a separate service or utility module. This separation would promote a clear distinction between API routing and business logic, ultimately enhancing maintainability.

- **Location:** `src/http/api/profile.ts` (lines 251–268)
- **Suggestion:** Evaluate refactoring the like/repost functionality into its own module or service layer. This change aligns with a layered architecture and component-based organization, ensuring that API controllers remain focused solely on handling HTTP requests and responses.

</details>
<details>
<summary>src/account/account.service.ts (3)</summary>

`19-21`: **Adding new table constants for database queries.**

The new table constants are properly imported to support the new methods added to the AccountService class.

---

`296-307`: **Well-implemented method for counting liked posts.**

The `getLikedCount` method is correctly implemented to count the number of posts liked by an account. The method follows the same pattern as other count methods in the service.

---

`309-324`: **Comprehensive post count implementation including both authored posts and reposts.**

The `getPostCount` method effectively combines counts from both authored posts and reposts, providing a complete picture of a user's posting activity. The implementation is efficient with separate queries that are then summed together.

</details>
<details>
<summary>features/profile-liked-post.feature (6)</summary>

`1-5`: **Clear feature definition for liked posts on profiles.**

The feature description clearly explains the user story and motivation behind the functionality, following good BDD practices.

---

`9-22`: **Well-structured scenario for basic liked posts query.**

This scenario effectively tests the core functionality of querying liked posts, ensuring that:
1. Only liked posts appear in the response
2. Unliked posts are excluded

---

`23-36`: **Good sorting test for liked posts.**

This scenario properly tests that liked posts are returned in descending order by date, which is important for a consistent user experience.

---

`37-59`: **Comprehensive pagination testing for liked posts.**

The scenario thoroughly tests the pagination functionality:
1. Tests limit parameter behavior
2. Verifies correct posts are returned based on the limit
3. Checks for the presence of a next cursor for further pagination
4. Validates that all posts can be retrieved with a larger limit

---

`60-62`: **Important error handling test for excessive limits.**

Testing that requests with limits over 100 are rejected is important for preventing abuse and ensuring system stability.

---

`64-72`: **Testing edge case of self-liked posts.**

This scenario verifies that users can see their own liked posts, which is an important edge case to test.

</details>
<details>
<summary>features/profile-posts.feature (5)</summary>

`1-5`: **Feature definition clearly describes the purpose of profile posts.**

The feature description effectively communicates the user story and intent behind the profile posts functionality.

---

`6-17`: **Background setup includes wait times to ensure consistent test results.**

The background steps include waiting periods between post creations to ensure they have different timestamps, which is important for predictable sorting behavior in the tests.

---

`19-24`: **Basic scenario for querying profile posts.**

This scenario effectively tests the main functionality of retrieving a user's own posts.

---

`25-34`: **Good test for filtering content from followed accounts.**

This scenario verifies that the profile posts endpoint only includes the user's own posts and not those from followed accounts, which is important for maintaining the separation between the feed and profile views.

---

`35-43`: **Verifying reposted content appears in profile posts.**

This scenario confirms that posts reposted by the user appear in their profile posts, which is an important aspect of the feature.

</details>
<details>
<summary>src/http/api/account.ts (4)</summary>

`1-5`: **No issues with import structure.**  
These import statements align well with the new code additions and keep type and utility references organized.

---

`14-14`: **Constants for profile posts limit are well-defined.**  
Defining `DEFAULT_PROFILE_POSTS_LIMIT` and `MAX_PROFILE_POSTS_LIMIT` is a neat approach to centralize configurations.  



Also applies to: 16-16, 19-19, 21-21

---

`24-24`: **Type definition for FollowAccount looks consistent.**  
The `FollowAccount` type is clear, ensuring strongly-typed return objects for follower/following queries.  



Also applies to: 26-29

---

`82-83`: <details>
<summary>❓ Verification inconclusive</summary>

**Confirm error handling for post and like count retrieval.**  
While calling `accountService.getPostCount(account)` and `accountService.getLikedCount(account)` is appropriate, consider verifying or logging potential errors if these calls fail or return unexpected values.  

Do other parts of the code handle potential exceptions from these calls or fallback defaults? You could confirm by searching for error handling across the repository:

---

🏁 Script executed:

```shell
#!/bin/bash
rg "getPostCount\(|getLikedCount\(" -A 10
```

Length of output: 2417

---



</details>

**Verify and Enhance Error Handling for Count Retrievals**

In the file `src/http/api/account.ts` (lines 82–83), the asynchronous calls to `accountService.getPostCount(account)` and `accountService.getLikedCount(account)` execute without explicit error management. Please verify whether errors from these calls are already being handled globally—such as via middleware or a top-level error boundary. If not, consider wrapping these calls in try-catch blocks or adding fallback/default values with logging to properly manage potential failures or unexpected return values.

</details>
<details>
<summary>src/post/post.service.ts (5)</summary>

`2-4`: **Import statements are properly updated.**  
These new imports for `AccountService`, `getAccountHandle`, and `FedifyContextFactory` align with the added code below.

---

`10-37`: **BaseGetProfileDataResultRow interface is well-structured.**  
The interface clearly enumerates the fields needed for mapping database rows to higher-level objects.

---

`39-58`: **Split interfaces for reposted vs. non-reposted posts.**  
Using `GetProfileDataResultRowReposted` and `GetProfileDataResultRowWithoutReposted` is a clean way to handle different row types while preserving strong typing.

---

`60-63`: **`GetProfileDataResult` organizes pagination well.**  
Structuring the result as `{ results, nextCursor }` simplifies the data flow for pagination.

---

`69-69`: **Knex instance injection is consistent with existing patterns.**  
Having the DB (`Knex`) accessible in the service fosters consistent data access across methods.

</details>
<details>
<summary>features/step_definitions/stepdefs.js (5)</summary>

`1705-1708`: **Wait step for test scenarios is straightforward.**  
The new step definition simplifies adding deliberate delays in the test flow. No issues noted.

---

`1843-1864`: **Flexible matching for posts, feed, and liked posts.**  
These regex-based steps help unify assertions across multiple response types. The `.find()` logic is simple and clear.

---

`1887-1908`: **Negative assertion step is consistent with the positive one.**  
Ensures we can confirm an item does not appear in the specified response type.

---

`1907-1917`: **Ensures presence of a pagination cursor.**  
This step definition verifies that the API supports multi-page results with a `next` cursor.

---

`1920-1921`: **Position-specific post assertion in the feed.**  
Allows verifying that a given post is in a particular position in the results array, improving test specificity.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-13T10:08:08Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 4**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>src/http/api/account.ts (1)</summary><blockquote>

`267-307`: _🛠️ Refactor suggestion_

**Consistency check for missing default account in liked-posts handler.**

Similar to the posts handler, if `accountService.getDefaultAccountForSite` returns `null`, the code will break when calling `account.id`. Add a null check to avoid runtime errors.  

```diff
const account = await accountService.getDefaultAccountForSite(
    ctx.get('site'),
);
+if (!account) {
+    return new Response(null, { status: 404 });
+}
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (7)</summary><blockquote>

<details>
<summary>src/http/api/profile.ts (1)</summary><blockquote>

`246-268`: **Enhanced post metrics implementation looks good.**

The code now correctly calculates and adds reply counts, repost counts, and like/repost status to the activity object. The implementation uses proper hash-based ID generation for consistency.


However, consider extracting this logic into a separate helper function to improve reusability, especially if similar logic needs to be applied elsewhere.


```diff
+ // Helper function to add engagement metrics to an activity
+ async function addEngagementMetricsToActivity(activity, apCtx, db) {
+   // Add reply count and repost count to the object
+   activity.object.replyCount = await getActivityChildrenCount(activity);
+   activity.object.repostCount = await getRepostCount(activity);
+
+   // Check if the activity is liked or reposted by default site account
+   const objectId = activity.object.id;
+   if (objectId) {
+     const likeId = apCtx.getObjectUri(Like, {
+       id: createHash('sha256').update(objectId).digest('hex'),
+     });
+     const repostId = apCtx.getObjectUri(Announce, {
+       id: createHash('sha256').update(objectId).digest('hex'),
+     });
+
+     const liked = (await db.get<string[]>(['liked'])) || [];
+     const reposted = (await db.get<string[]>(['reposted'])) || [];
+
+     activity.object.liked = liked.includes(likeId.href);
+     activity.object.reposted = reposted.includes(repostId.href);
+   }
+   return activity;
+ }
```

This could then be used in the handler with:
```javascript
activity = await addEngagementMetricsToActivity(activity, apCtx, db);
```

</blockquote></details>
<details>
<summary>features/profile-posts.feature (2)</summary><blockquote>

`11-16`: **Explicit wait time to prevent test flakiness.**

The explicit wait between post creation helps prevent flakiness in tests. As noted in the previous review, this is necessary because posts are currently sorted by created_at timestamp and without the wait, posts could be created at exactly the same time.


Consider adding a comment in the feature file to explain why this wait is necessary, for better documentation.

---

`45-49`: **Inconsistent naming in scenario titles.**

The scenario titles refer to "Liked posts" but they are actually testing profile posts functionality. This could cause confusion.


```diff
-  Scenario: Liked posts are sorted by date descending
+  Scenario: Profile posts are sorted by date descending
   When an authenticated request is made to "/.ghost/activitypub/posts"
   Then the request is accepted
   And post "1" in the "posts" response is "MyNote2"
   And post "2" in the "posts" response is "MyNote"

-  Scenario: Liked posts are paginated
+  Scenario: Profile posts are paginated
   Given we create a note "MyNote3" with the content
     """
     Hello World 3
     """
```


Also applies to: 52-67

</blockquote></details>
<details>
<summary>src/account/account.service.ts (1)</summary><blockquote>

`311-326`: **Post count method includes both authored and reposted content.**

The `getPostCount` method correctly counts both authored posts and reposts, providing a comprehensive count of the user's content.


Consider combining the two database queries into a single query for potentially better performance:


```diff
async getPostCount(account: AccountType): Promise<number> {
-    const posts = await this.db(TABLE_POSTS)
-        .where('author_id', account.id)
-        .count('*', { as: 'count' });
-
-    const reposts = await this.db(TABLE_REPOSTS)
-        .where('account_id', account.id)
-        .count('*', { as: 'count' });
-
-    return Number(posts[0].count) + Number(reposts[0].count);
+    const result = await this.db
+        .select(this.db.raw('count(*) as count'))
+        .from(function() {
+            this.select(1)
+                .from(TABLE_POSTS)
+                .where('author_id', account.id)
+                .unionAll(function() {
+                    this.select(1)
+                        .from(TABLE_REPOSTS)
+                        .where('account_id', account.id);
+                })
+                .as('combined_posts');
+        });
+
+    return Number(result[0].count);
}
```

This approach would use a UNION ALL to combine both queries into a single database request.

</blockquote></details>
<details>
<summary>src/post/post.service.ts (2)</summary><blockquote>

`69-69`: **Injecting `Knex` directly complements repository usage.**

While mixing direct DB usage with a repository can add some complexity, it's acceptable if carefully managed and well-documented. Ensure usage is consistent throughout the codebase to maintain clarity.

---

`395-485`: **Optimization possibility for `likes.id` pagination.**

You're paginating by descending `likes.id`. If large volumes of liked posts are expected, ensure `likes.id` is indexed to avoid inefficient scans.  

```diff
# Example migration snippet:
- CREATE TABLE likes ( ... );
+ CREATE TABLE likes (
+   id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+   ...
+   INDEX (id),
+ );
```

</blockquote></details>
<details>
<summary>features/step_definitions/stepdefs.js (1)</summary><blockquote>

`1919-1933`: **Parameterized step for verifying specific object in a specific position.**

The step `post {string} in the {string} response is {string}` is clear and direct. Ensure test data indicates zero-based vs. one-based indexing. Currently, the code uses `(Number(postNumber) - 1)`.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 46341287e46eab57d343b2984e72d17ee43a1abe and 833847c540c0ef31596235a5a6561b47ae7dd435.

</details>

<details>
<summary>📒 Files selected for processing (11)</summary>

* `features/delete-post.feature` (2 hunks)
* `features/feed.feature` (8 hunks)
* `features/handle-delete.feature` (1 hunks)
* `features/profile-liked-post.feature` (1 hunks)
* `features/profile-posts.feature` (1 hunks)
* `features/step_definitions/stepdefs.js` (4 hunks)
* `src/account/account.service.ts` (2 hunks)
* `src/app.ts` (3 hunks)
* `src/http/api/account.ts` (4 hunks)
* `src/http/api/profile.ts` (2 hunks)
* `src/post/post.service.ts` (2 hunks)

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: Build, Test and Deploy

</details>

<details>
<summary>🔇 Additional comments (31)</summary><blockquote>

<details>
<summary>features/delete-post.feature (3)</summary>

`17-18`: **Phrase consistency improvement across test assertions**

The updated phrasing "is in the feed" is more readable and consistent with the changes in other feature files. This matches the formatting that was discussed and agreed upon in previous reviews.

---

`25-25`: **Consistent phrasing for negative assertions**

The updated phrasing "is not in the feed" matches the positive assertion format and improves readability.

---

`34-34`: **Consistent assertion formatting**

This change follows the same pattern as the others, maintaining consistency across all feed-related assertions.

</details>
<details>
<summary>features/feed.feature (4)</summary>

`18-19`: **Improved assertion phrasing for presence/absence in feed**

The revised phrasing is more natural and consistent, improving test readability.

---

`30-31`: **Clarification of response context with quoted term**

Adding quotes around 'feed' in "in the 'feed' response" clarifies that you're specifically referring to the feed response structure, which is more precise than the previous phrasing.

---

`45-48`: **Consistent formatting for feed pagination assertions**

All feed-related assertions now follow the same pattern, making the tests more maintainable and easier to understand.

---

`51-51`: **Unified assertion language throughout feature file**

The standardized phrasing for feed assertions makes the entire feature file more cohesive and easier to scan.



Also applies to: 64-64, 74-74, 82-82, 92-92, 105-106, 120-122

</details>
<details>
<summary>features/handle-delete.feature (1)</summary>

`30-30`: **Consistent phrasing for negative assertions**

The updated phrasing "is not in the feed" aligns with the positive assertion format "is in the feed", bringing consistency to the test language.



Also applies to: 36-36

</details>
<details>
<summary>src/app.ts (3)</summary>

`102-103`: **Added new handler imports for profile post operations**

These imports support the new profile post count features being added. The naming is clear and follows the existing pattern.

---

`248-253`: **Updated PostService constructor with client parameter**

The PostService now accepts a database client parameter, which will be needed for the new functionality to count posts and likes.

---

`960-969`: **Added new protected routes for profile post data**

Added two new GET routes that will provide the post count data needed for user profiles. Both routes are properly protected with the Owner role requirement and wrapped with spanWrapper for instrumentation.


These endpoints align with the PR objective of showing repost and like counts on user profiles, providing the necessary API endpoints to fetch this data.

</details>
<details>
<summary>src/http/api/profile.ts (3)</summary>

`1-1`: **Update import statements to use absolute imports for consistency.**

The project appears to use absolute imports in other places, but these imports are using relative-style paths. For consistency, update these import statements to use absolute imports.



```diff
-import { createHash } from 'node:crypto';
+import { createHash } from 'node:crypto';
 import {
     Activity,
     type Actor,
-    Announce,
+    Announce,
     type CollectionPage,
-    Like,
+    Like,
     isActor,
 } from '@fedify/fedify';

-import type { AccountService } from 'account/account.service';
-import { type AppContext, fedify } from 'app';
-import { getActivityChildrenCount, getRepostCount } from 'db';
+import type { AccountService } from '/account/account.service';
+import { type AppContext, fedify } from '/app';
+import { getActivityChildrenCount, getRepostCount } from '/db';
```


Also applies to: 5-5, 7-7, 11-13

---

`21-24`: **Update import statements to use absolute imports for consistency.**

These imports should also use absolute paths for consistency with the project's import style.



```diff
-} from 'helpers/activitypub/actor';
-import { sanitizeHtml } from 'helpers/html';
-import { isUri } from 'helpers/uri';
-import { lookupObject } from 'lookup-helpers';
+} from '/helpers/activitypub/actor';
+import { sanitizeHtml } from '/helpers/html';
+import { isUri } from '/helpers/uri';
+import { lookupObject } from '/lookup-helpers';
```

---

`39-119`: **Consider moving profile handlers to a more appropriate location.**

As mentioned in the previous review comments, the "profile" naming is a hangover from before the idea of an "account" was introduced. These functions might be better located in a `posts.ts` or `account.ts` file since they're retrieving data directly from the database.

</details>
<details>
<summary>features/profile-liked-post.feature (5)</summary>

`1-22`: **Comprehensive test coverage for liked posts feature.**

Good implementation of the feature file with clear scenarios for querying liked posts. The test covers essential functionality like verifying that liked and non-liked posts are correctly identified.

---

`23-36`: **Properly testing sort order of liked posts.**

Good test case ensuring that liked posts are sorted in descending order by date. This verifies that the most recent posts appear first.

---

`37-59`: **Thorough pagination testing.**

Excellent implementation of pagination testing. The test ensures that:
1. Limiting results works correctly
2. The right posts appear in the limited results
3. The next cursor is provided when more results are available
4. Additional posts can be fetched with subsequent requests

This provides comprehensive coverage of the pagination functionality.

---

`60-63`: **API validation test for excessive limits.**

Good test case for validating API input parameters. This ensures the API correctly rejects requests with limits that exceed the maximum allowed value.

---

`64-72`: **Test for edge case of liking own posts.**

Good coverage of the edge case where a user likes their own posts. This ensures that a user's own posts are correctly included in their liked posts list when liked.

</details>
<details>
<summary>src/account/account.service.ts (2)</summary>

`19-21`: **Import table constants added correctly.**

Good addition of these table constants for use in the new methods.

---

`296-309`: **Well-implemented method for getting liked post count.**

The `getLikedCount` method is well implemented with appropriate filtering to exclude replies. The method correctly:
1. Joins the likes and posts tables
2. Filters by the account ID
3. Excludes posts that are replies
4. Counts the results and returns the number

Good job on the implementation.

</details>
<details>
<summary>src/http/api/account.ts (1)</summary>

`227-265`: **Handle missing default account gracefully.**

If `accountService.getDefaultAccountForSite(ctx.get('site'))` returns `null`, attempting to access `account.id` will throw. Consider adding a null check to return an error response (e.g., 404) when no default account is found.  
This issue was highlighted in a past review comment.

</details>
<details>
<summary>src/post/post.service.ts (5)</summary>

`2-3`: **Imports look good.**  
No issues with these newly introduced import statements.

---

`10-59`: **Interfaces for profile data rows are well-structured.**  
These interfaces provide a clear, consistent schema for typed rows. Consider segregating them into a separate types file if they grow large, but currently they look fine.

---

`60-63`: **`GetProfileDataResult` type is clear and concise.**  
This helps in returning a uniform shape for both posts and liked posts queries.

---

`73-132`: **Potential parse error for `author_url`.**

`new URL(result.author_url)` could throw if `author_url` is not a valid URL. Ensure that upstream data is validated or wrap parsing in a try/catch block for robust error handling.  

```diff
handle: getAccountHandle(
-    result.author_url ? new URL(result.author_url).host : '',
+    (function(){
+       try {
+         return result.author_url ? new URL(result.author_url).host : '';
+       } catch(e) {
+         return '';
+       }
+    }()),
    result.author_username,
),
```
Would you like to verify if all `author_url` values are guaranteed to be valid in the database?

---

`229-393`: **Revisit the UNION query for potential indexing improvements.**

This query unifies original posts and reposts, sorted by `created_at`. For large datasets, an index on `created_at` in both tables (`posts`, `reposts`) can significantly improve performance. If no index exists, consider adding one in your migrations. This was suggested previously in a past review.

</details>
<details>
<summary>features/step_definitions/stepdefs.js (4)</summary>

`1705-1707`: **Useful asynchronous delay step.**

This step adds valuable flexibility for controlling test timing in complex scenarios.

---

`1843-1864`: **Regex-based matching for feed presence.**

Switching to a regex step definition (e.g., `/"([^"]*)" is in the (posts|feed|liked posts)/`) is a good approach to unify similar steps. Be sure to confirm older step definitions that might conflict are removed or updated to avoid confusion in your test suite.

---

`1887-1905`: **Consistent step for feed absence.**

Using a similar regex-based approach for asserting an item is absent from the feed or liked posts is a consistent improvement.

---

`1907-1917`: **Enhancement: Checking for presence of next cursor.**

The introduction of `the (posts|feed|liked posts) response has a next cursor` ensures robust coverage for pagination. This is a well-structured and reusable step definition.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-13T12:06:47Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>features/profile-posts.feature (2)</summary><blockquote>

`44-49`: **Fix scenario title to match the tested functionality.**

The scenario title incorrectly refers to "Liked posts" but is actually testing the sorting of profile posts.

```diff
-  Scenario: Liked posts are sorted by date descending
+  Scenario: Profile posts are sorted by date descending
```

---

`50-66`: **Fix scenario title and verify pagination functionality.**

The scenario title incorrectly refers to "Liked posts" but is testing pagination of profile posts. The scenario itself correctly tests that pagination works as expected with the limit parameter, showing the appropriate number of posts and providing a cursor for the next page.

```diff
-  Scenario: Liked posts are paginated
+  Scenario: Profile posts are paginated
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 833847c540c0ef31596235a5a6561b47ae7dd435 and 722689b4fe3fbc8090ba3164ce3b61abbbe3c574.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `features/profile-posts.feature` (1 hunks)
* `src/app.ts` (3 hunks)

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: Build, Test and Deploy

</details>

<details>
<summary>🔇 Additional comments (7)</summary><blockquote>

<details>
<summary>src/app.ts (3)</summary>

`102-103`: **Added required API handler imports for new post-related endpoints.**

These imports correspond to the new handlers that will be used by the added routes to show repost and like count on user profiles, reflecting the PR's objective.

---

`257-262`: **Updated PostService constructor to include client parameter.**

The PostService instantiation now includes the database client as a parameter, which is necessary for the service to interact with the database when fetching post and like counts.

---

`969-978`: **Added new routes for profile post functionality.**

Two new API endpoints have been added:
1. `/.ghost/activitypub/posts` - Retrieves a user's own posts (including reposts)
2. `/.ghost/activitypub/posts/liked` - Retrieves posts that the user has liked

Both endpoints are protected with the Owner role requirement and utilize span wrappers for monitoring, following the established pattern in the application.

</details>
<details>
<summary>features/profile-posts.feature (4)</summary>

`6-18`: **Background setup for profile posts feature tests.**

The background properly sets up the test environment by creating two notes with a delay between them (to ensure different timestamps) and establishing a follow relationship with "Alice".


The 1000 millisecond wait between note creation is necessary to avoid flakiness in tests that rely on sorting by creation time, as noted in a previous comment.

---

`19-24`: **Base scenario for querying profile posts.**

This scenario correctly tests the basic functionality of the new endpoint, ensuring users can retrieve their own posts and that multiple posts are returned.

---

`25-34`: **Scenario for filtering out posts from followed accounts.**

This scenario verifies that the posts endpoint only includes the user's own posts and not posts from accounts they follow, maintaining the expected separation between feed content and profile content.

---

`35-43`: **Scenario for including reposted content.**

This scenario verifies that content reposted by the user appears in their profile posts, correctly including shared content as part of the user's profile activity.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-13T12:10:52Z
user: login: mike182uk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T13:47:47Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-13T13:47:56Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (3)</summary><blockquote>

<details>
<summary>src/http/api/account.ts (3)</summary><blockquote>

`208-226`: _🛠️ Refactor suggestion_

**Add validation for non-positive limit values.**

While the function correctly validates against the maximum limit, it doesn't check if the limit is a positive integer. Non-positive or non-numeric values could lead to unexpected behavior.



```diff
function validateRequestParams(ctx: AppContext) {
    const queryCursor = ctx.req.query('next');
    const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;

    const queryLimit = ctx.req.query('limit');
    const limit = queryLimit ? Number(queryLimit) : DEFAULT_POSTS_LIMIT;

+    // Ensure limit is a positive integer
+    if (!Number.isInteger(limit) || limit <= 0) {
+        return null;
+    }
    if (limit > MAX_POSTS_LIMIT) {
        return null;
    }

    return { cursor, limit };
}
```

---

`228-266`: _⚠️ Potential issue_

**Verify account exists before proceeding.**

`handleGetPosts` doesn't check if `account` exists before using it. If `getDefaultAccountForSite` returns null (which is possible based on its usage in `handleGetAccount`), this would lead to a runtime error when accessing `account.id`.



```diff
    return async function handleGetPosts(ctx: AppContext) {
        const params = validateRequestParams(ctx);
        if (!params) {
            return new Response(null, { status: 400 });
        }

        const account = await accountService.getDefaultAccountForSite(
            ctx.get('site'),
        );
+        if (!account) {
+            return new Response(null, { status: 404 });
+        }
        const { results, nextCursor } = await postService.getPostsByAccount(
            account.id,
            params.limit,
            params.cursor,
        );
```

---

`268-307`: _⚠️ Potential issue_

**Verify account exists before proceeding.**

Similar to the previous handler, `handleGetLikedPosts` should check if `account` exists before using it to avoid potential runtime errors.


```diff
    return async function handleGetLikedPosts(ctx: AppContext) {
        const params = validateRequestParams(ctx);
        if (!params) {
            return new Response(null, { status: 400 });
        }

        const account = await accountService.getDefaultAccountForSite(
            ctx.get('site'),
        );
+        if (!account) {
+            return new Response(null, { status: 404 });
+        }

        const { results, nextCursor } =
            await postService.getPostsLikedByAccount(
                account.id,
                params.limit,
                params.cursor,
            );
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>src/http/api/account.ts (2)</summary><blockquote>

`258-264`: **Add Content-Type header to the response.**

The response is missing the 'Content-Type' header which should be 'application/json' for JSON responses. This is inconsistent with other handlers in this file (like `handleGetAccount` and `handleGetAccountFollows`).


```diff
        return new Response(
            JSON.stringify({
                posts: results,
                next: nextCursor,
            }),
-            { status: 200 },
+            { 
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                status: 200 
+            },
        );
```

---

`300-306`: **Add Content-Type header to the response.**

The response is missing the 'Content-Type' header which should be 'application/json' for JSON responses, similar to the issue in the previous handler.


```diff
        return new Response(
            JSON.stringify({
                posts: results,
                next: nextCursor,
            }),
-            { status: 200 },
+            { 
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                status: 200 
+            },
        );
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 722689b4fe3fbc8090ba3164ce3b61abbbe3c574 and 9b05f1f4e40fbe6bc5941b7147bf9a9d3902f743.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/http/api/account.ts` (4 hunks)

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: Build, Test and Deploy

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>src/http/api/account.ts (3)</summary>

`1-5`: **Clean import updates enhance code organization.**

The import paths have been simplified and the addition of the `PostService` import supports the new functionality for post retrieval.

---

`14-21`: **Well-defined pagination constants but consider renaming.**

The constants for pagination limits are clearly defined with descriptive comments. However, as suggested in previous comments, consider simplifying the names to just `DEFAULT_POSTS_LIMIT` and `MAX_POSTS_LIMIT` without the "PROFILE" prefix for consistency.

---

`82-83`: **Good use of service methods for count retrieval.**

Moving the post and like count retrieval to service methods (`accountService.getPostCount` and `accountService.getLikedCount`) improves code organization by centralizing this logic in the service layer.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-13T14:07:20Z
user: login: vershwal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T14:07:37Z

Review comments:
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
path: src/profile/profile.service.ts
user: login: vershwal
body: The query in this function:-
- fetches posts an account has created or reposted. 
- It sorts everything by created_at. 
- It also pulls in author and reposter details
- checks if the current user liked or reposted a post.
created_at: 2025-03-12T13:33:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN likes.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
+
+    /**
+     * Get posts liked by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsLikedByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db('likes')
path: src/profile/profile.service.ts
user: login: vershwal
body: This query:- 
- gets posts the current user has liked
- pulls in post details, author info, and checking if the current user also reposted them. 
- It sorts by likes.id to show the most recently liked posts first
created_at: 2025-03-12T13:35:30Z
author_association: MEMBER
diff_hunk: @@ -534,3 +573,104 @@ export function createGetProfileFollowingHandler(
         });
     };
 }
+
+/**
+ * Validates and extracts pagination parameters from the request
+ * @param ctx App context
+ * @returns Object containing cursor and limit, or null if invalid
+ */
+function validateRequestParams(ctx: AppContext) {
+    const queryCursor = ctx.req.query('next');
+    const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;
+
+    const queryLimit = ctx.req.query('limit');
+    const limit = queryLimit ? Number(queryLimit) : DEFAULT_PROFILE_POSTS_LIMIT;
+
+    if (limit > MAX_PROFILE_POSTS_LIMIT) {
+        return null;
+    }
+
+    return { cursor, limit };
+}
+
+/**
+ * Create a handler to handle a request for a list of posts by an account
+ *
+ * @param accountService Account service instance
+ * @param profileService Profile service instance
+ */
+export function createGetPostsHandler(
+    accountService: AccountService,
+    profileService: ProfileService,
+) {
+    /**
+     * Handle a request for a list of posts by an account
+     *
+     * @param ctx App context
+     */
+    return async function handleGetPosts(ctx: AppContext) {
+        const params = validateRequestParams(ctx);
+        if (!params) {
+            return new Response(null, { status: 400 });
+        }
+
+        const account = await accountService.getDefaultAccountForSite(
+            ctx.get('site'),
+        );
+        const { results, nextCursor } = await profileService.getPostsByAccount(
+            account.id,
+            params.limit,
+            params.cursor,
+        );
+
+        return new Response(
+            JSON.stringify({
+                posts: results,
+                next: nextCursor,
+            }),
+            { status: 200 },
+        );
+    };
+}
+
+/**
+ * Create a handler to handle a request for a list of posts liked by an account
+ *
+ * @param accountService Account service instance
+ * @param profileService Profile service instance
+ */
+export function createGetLikedPostsHandler(
+    accountService: AccountService,
+    profileService: ProfileService,
+) {
+    /**
+     * Handle a request for a list of posts liked by an account
+     *
+     * @param ctx App context
+     */
+    return async function handleGetLikedPosts(ctx: AppContext) {
+        const params = validateRequestParams(ctx);
+        if (!params) {
+            return new Response(null, { status: 400 });
+        }
+
+        const account = await accountService.getDefaultAccountForSite(
+            ctx.get('site'),
+        );
+
+        const { results, nextCursor } =
+            await profileService.getPostsLikedByAccount(
+                account.id,
+                params.limit,
+                params.cursor,
+            );
+
+        return new Response(
+            JSON.stringify({
+                posts: results,
+                next: nextCursor,
+            }),
+            { status: 200 },
+        );
+    };
+}
path: src/http/api/profile.ts
user: login: mike182uk
body: 🤔 I think these would be be better off in a `posts.ts` or `account.ts` (i.e `createGetAccountPostsHandler`)

The "profile" naming is a bit of a hangover before we had the idea of an "account". This file is basically functions needed to retrieve a "remote profile" which is not what we are doing here (because we have direct access to the db)

What do you think?
created_at: 2025-03-12T17:45:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
path: src/profile/profile.service.ts
user: login: mike182uk
body: I don't think we need a new service here - we could put the post retrieval methods on the post repository / service maybe?
created_at: 2025-03-12T17:47:58Z
author_association: MEMBER
diff_hunk: @@ -14,15 +14,15 @@ Feature: Delete a post
     And "OurNote" is in our Outbox
     And an authenticated request is made to "/.ghost/activitypub/feed"
     And the request is accepted
-    And the feed contains "OurNote"
-    And the feed contains "AliceNote"
+    And the "feed" response contains "OurNote"
+    And the "feed" response contains "AliceNote"
path: features/delete-post.feature
user: login: mike182uk
body: Hm, I think I prefer the wording of the original, but i get why you are wanting to do this

How about:

- Feed: `"OurNote" is in the feed`
- Posts: `"OurNote" is in the posts`
- Liked Posts: `"OurNote" is in the liked posts`

You can use a regex when declaring a step definition, we could maybe capture all 3 of these using the same step definition - pseudo regex: `/(.*) is in the (post|liked posts) (posts|feed)/` 

What do you think?
created_at: 2025-03-12T17:58:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,40 @@
+Feature: My Posts on Profile
+  In order to see my posts on my profile
+  As a user
+  I want to query my posts
+
+  Background:
+    Given we create a note "MyNote" with the content
+      """
+      Hello World 1
+      """
+    And we create a note "MyNote2" with the content
+      """
+      Hello World 2
+      """
+    And we are following "Alice"
+
+  Scenario: Querying profile posts
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And the "posts" response contains "MyNote"
+    And the "posts" response contains "MyNote2"
+
+  Scenario: My posts does not contain posts from followed accounts
+    Given a "Create(Note)" Activity "AliceNote" by "Alice"
+    And "Alice" sends "AliceNote" to the Inbox
+    And "AliceNote" is in our Inbox
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And the "posts" response contains "MyNote"
+    And the "posts" response contains "MyNote2"
+    And the "posts" response does not contain "AliceNote"
+
+  Scenario: Profile posts includes posts we reposted
+    Given a "Create(Note)" Activity "AliceNote" by "Alice"
+    And "Alice" sends "AliceNote" to the Inbox
+    And "AliceNote" is in our Inbox
+    And we repost the object "AliceNote"
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And the "posts" response contains "AliceNote"
path: features/profile-posts.feature
user: login: mike182uk
body: Should this have the same scenarios as you've done for liked posts? (i.e sorting, pagination etc.)
created_at: 2025-03-12T18:00:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,382 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN likes.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+                'posts_with_source.deleted_at',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        'posts.deleted_at',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'posts.deleted_at',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .where('posts_with_source.deleted_at', null)
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
+
+    /**
+     * Get posts liked by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsLikedByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db('likes')
+            .select(
+                'likes.id as likes_id',
+                // Post fields
+                'posts.id as post_id',
+                'posts.type as post_type',
+                'posts.title as post_title',
+                'posts.excerpt as post_excerpt',
+                'posts.content as post_content',
+                'posts.url as post_url',
+                'posts.image_url as post_image_url',
+                'posts.published_at as post_published_at',
+                'posts.like_count as post_like_count',
+                this.db.raw('1 AS post_liked_by_user'), // Since we are selecting from `likes`, this is always 1
+                'posts.reply_count as post_reply_count',
+                'posts.reading_time_minutes as post_reading_time_minutes',
+                'posts.attachments as post_attachments',
+                'posts.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE
+                        WHEN reposts.post_id IS NOT NULL THEN 1
+                        ELSE 0
+                    END AS post_reposted_by_user`,
+                ),
+                'posts.ap_id as post_ap_id',
+                // Author fields
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+            )
+            .innerJoin('posts', 'posts.id', 'likes.post_id')
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts.author_id',
+            )
+            .leftJoin('reposts', function () {
+                this.on('reposts.post_id', 'posts.id').andOnVal(
+                    'reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'reposts.account_id',
+            )
+            .where('likes.account_id', accountId)
+            .modify((query) => {
+                if (cursor) {
+                    query.where('likes.id', '<', cursor);
+                }
+            })
+            .where('posts.deleted_at', null)
path: src/profile/profile.service.ts
user: login: vershwal
body: Added this to remove deleted posts from profile but we need a better solution for this. 
When a post is deleted we [remove it from the feed](https://github.com/TryGhost/ActivityPub/blob/main/src/feed/feed-update.service.ts#L87) but it is not removed from likes table and reposts table. This also creates inconsistency while fetching the likesCount for a user.
created_at: 2025-03-12T18:09:03Z
author_association: MEMBER
diff_hunk: @@ -534,3 +573,104 @@ export function createGetProfileFollowingHandler(
         });
     };
 }
+
+/**
+ * Validates and extracts pagination parameters from the request
+ * @param ctx App context
+ * @returns Object containing cursor and limit, or null if invalid
+ */
+function validateRequestParams(ctx: AppContext) {
+    const queryCursor = ctx.req.query('next');
+    const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;
+
+    const queryLimit = ctx.req.query('limit');
+    const limit = queryLimit ? Number(queryLimit) : DEFAULT_PROFILE_POSTS_LIMIT;
+
+    if (limit > MAX_PROFILE_POSTS_LIMIT) {
+        return null;
+    }
+
+    return { cursor, limit };
+}
+
+/**
+ * Create a handler to handle a request for a list of posts by an account
+ *
+ * @param accountService Account service instance
+ * @param profileService Profile service instance
+ */
+export function createGetPostsHandler(
+    accountService: AccountService,
+    profileService: ProfileService,
+) {
+    /**
+     * Handle a request for a list of posts by an account
+     *
+     * @param ctx App context
+     */
+    return async function handleGetPosts(ctx: AppContext) {
+        const params = validateRequestParams(ctx);
+        if (!params) {
+            return new Response(null, { status: 400 });
+        }
+
+        const account = await accountService.getDefaultAccountForSite(
+            ctx.get('site'),
+        );
+        const { results, nextCursor } = await profileService.getPostsByAccount(
+            account.id,
+            params.limit,
+            params.cursor,
+        );
+
+        return new Response(
+            JSON.stringify({
+                posts: results,
+                next: nextCursor,
+            }),
+            { status: 200 },
+        );
+    };
+}
+
+/**
+ * Create a handler to handle a request for a list of posts liked by an account
+ *
+ * @param accountService Account service instance
+ * @param profileService Profile service instance
+ */
+export function createGetLikedPostsHandler(
+    accountService: AccountService,
+    profileService: ProfileService,
+) {
+    /**
+     * Handle a request for a list of posts liked by an account
+     *
+     * @param ctx App context
+     */
+    return async function handleGetLikedPosts(ctx: AppContext) {
+        const params = validateRequestParams(ctx);
+        if (!params) {
+            return new Response(null, { status: 400 });
+        }
+
+        const account = await accountService.getDefaultAccountForSite(
+            ctx.get('site'),
+        );
+
+        const { results, nextCursor } =
+            await profileService.getPostsLikedByAccount(
+                account.id,
+                params.limit,
+                params.cursor,
+            );
+
+        return new Response(
+            JSON.stringify({
+                posts: results,
+                next: nextCursor,
+            }),
+            { status: 200 },
+        );
+    };
+}
path: src/http/api/profile.ts
user: login: vershwal
body: I was expecting some comments about where these functions should live 😬.
 Honestly, I don’t have a strong preference for keeping them in `profile.ts` and `profile.service.ts`. I initially kept these in `account.ts`, I think it does make sense to move them to `posts.ts` or `account.ts`
created_at: 2025-03-12T18:16:23Z
author_association: MEMBER
diff_hunk: @@ -14,15 +14,15 @@ Feature: Delete a post
     And "OurNote" is in our Outbox
     And an authenticated request is made to "/.ghost/activitypub/feed"
     And the request is accepted
-    And the feed contains "OurNote"
-    And the feed contains "AliceNote"
+    And the "feed" response contains "OurNote"
+    And the "feed" response contains "AliceNote"
path: features/delete-post.feature
user: login: vershwal
body: Yeah, I do agree these are messy and less readable this way. I'll clean them. 
created_at: 2025-03-12T18:17:15Z
author_association: MEMBER
diff_hunk: @@ -18,6 +22,7 @@ import {
 import { sanitizeHtml } from '../../helpers/html';
 import { isUri } from '../../helpers/uri';
 import { lookupObject } from '../../lookup-helpers';
+import type { ProfileService } from '../../profile/profile.service';
path: src/http/api/profile.ts
user: login: allouis
body: I think we agreed to use absolute imports? So this would be `import ... from 'profile/profile.service';`
created_at: 2025-03-13T03:03:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,382 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN likes.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+                'posts_with_source.deleted_at',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        'posts.deleted_at',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'posts.deleted_at',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .where('posts_with_source.deleted_at', null)
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
+
+    /**
+     * Get posts liked by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsLikedByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db('likes')
+            .select(
+                'likes.id as likes_id',
+                // Post fields
+                'posts.id as post_id',
+                'posts.type as post_type',
+                'posts.title as post_title',
+                'posts.excerpt as post_excerpt',
+                'posts.content as post_content',
+                'posts.url as post_url',
+                'posts.image_url as post_image_url',
+                'posts.published_at as post_published_at',
+                'posts.like_count as post_like_count',
+                this.db.raw('1 AS post_liked_by_user'), // Since we are selecting from `likes`, this is always 1
+                'posts.reply_count as post_reply_count',
+                'posts.reading_time_minutes as post_reading_time_minutes',
+                'posts.attachments as post_attachments',
+                'posts.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE
+                        WHEN reposts.post_id IS NOT NULL THEN 1
+                        ELSE 0
+                    END AS post_reposted_by_user`,
+                ),
+                'posts.ap_id as post_ap_id',
+                // Author fields
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+            )
+            .innerJoin('posts', 'posts.id', 'likes.post_id')
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts.author_id',
+            )
+            .leftJoin('reposts', function () {
+                this.on('reposts.post_id', 'posts.id').andOnVal(
+                    'reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'reposts.account_id',
+            )
+            .where('likes.account_id', accountId)
+            .modify((query) => {
+                if (cursor) {
+                    query.where('likes.id', '<', cursor);
+                }
+            })
+            .where('posts.deleted_at', null)
path: src/profile/profile.service.ts
user: login: allouis
body: Yeah we can't filter on the `posts` table here, it's gonna kill performance, because it's going to go from a covering index to scanning the posts table

We need to confirm if the like should be deleted or not - perhaps product would prefer to preserve it and have a record of liking something that is now gone. If not then we need to delete from the likes table when a post is deleted!
created_at: 2025-03-13T03:11:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
path: src/profile/profile.service.ts
user: login: vershwal
body: ```
SELECT 
    -- Post fields
    posts_with_source.id AS post_id,
    posts_with_source.type AS post_type,
    posts_with_source.title AS post_title,
    posts_with_source.excerpt AS post_excerpt,
    posts_with_source.content AS post_content,
    posts_with_source.url AS post_url,
    posts_with_source.image_url AS post_image_url,
    posts_with_source.published_at AS post_published_at,
    posts_with_source.like_count AS post_like_count,
    CASE 
        WHEN likes.post_id IS NOT NULL THEN 1 
        ELSE 0 
    END AS post_liked_by_user,
    posts_with_source.reply_count AS post_reply_count,
    posts_with_source.reading_time_minutes AS post_reading_time_minutes,
    posts_with_source.attachments AS post_attachments,
    posts_with_source.repost_count AS post_repost_count,
    CASE 
        WHEN user_reposts.post_id IS NOT NULL THEN 1 
        ELSE 0 
    END AS post_reposted_by_user,
    posts_with_source.ap_id AS post_ap_id,
    -- Author fields (Who originally created the post)
    author_account.id AS author_id,
    author_account.name AS author_name,
    author_account.username AS author_username,
    author_account.url AS author_url,
    author_account.avatar_url AS author_avatar_url,
    -- Reposter fields (If applicable)
    reposter_account.id AS reposter_id,
    reposter_account.name AS reposter_name,
    reposter_account.username AS reposter_username,
    reposter_account.url AS reposter_url,
    reposter_account.avatar_url AS reposter_avatar_url,
    -- Unified `created_at` field for sorting
    posts_with_source.created_at,
    posts_with_source.deleted_at
FROM (
    -- Original posts by the account
    SELECT 
        posts.id,
        posts.type,
        posts.title,
        posts.excerpt,
        posts.content,
        posts.url,
        posts.image_url,
        posts.published_at,
        posts.like_count,
        posts.reply_count,
        posts.reading_time_minutes,
        posts.attachments,
        posts.repost_count,
        posts.ap_id,
        posts.author_id,
        posts.created_at,
        posts.deleted_at,
        NULL AS reposter_id,
        'original' AS source
    FROM posts
    WHERE posts.author_id = '<accountId>'
    
    UNION ALL
    
    -- Reposts made by the account
    SELECT 
        posts.id,
        posts.type,
        posts.title,
        posts.excerpt,
        posts.content,
        posts.url,
        posts.image_url,
        posts.published_at,
        posts.like_count,
        posts.reply_count,
        posts.reading_time_minutes,
        posts.attachments,
        posts.repost_count,
        posts.ap_id,
        posts.author_id,
        reposts.created_at AS created_at,
        posts.deleted_at,
        reposts.account_id AS reposter_id,
        'repost' AS source
    FROM reposts
    INNER JOIN posts ON posts.id = reposts.post_id
    WHERE reposts.account_id = '<accountId>'
) AS posts_with_source
-- Joining authors (post creators)
INNER JOIN accounts AS author_account ON author_account.id = posts_with_source.author_id
-- Joining reposters (if applicable)
LEFT JOIN accounts AS reposter_account ON reposter_account.id = posts_with_source.reposter_id
-- Joining likes to check if the user liked the post
LEFT JOIN likes ON likes.post_id = posts_with_source.id 
    AND likes.account_id = '<accountId>'
-- Joining reposts to check if the user reposted the post
LEFT JOIN reposts AS user_reposts ON user_reposts.post_id = posts_with_source.id 
    AND user_reposts.account_id = '<accountId>'
-- Cursor-based pagination
WHERE posts_with_source.deleted_at IS NULL
AND (posts_with_source.created_at < '<cursor>' OR '<cursor>' IS NULL)
ORDER BY posts_with_source.created_at DESC
LIMIT <limit> + 1;
```
created_at: 2025-03-13T05:36:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN likes.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
+
+    /**
+     * Get posts liked by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsLikedByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db('likes')
path: src/profile/profile.service.ts
user: login: vershwal
body: ```
SELECT 
    likes.id AS likes_id,
    -- Post fields
    posts.id AS post_id,
    posts.type AS post_type,
    posts.title AS post_title,
    posts.excerpt AS post_excerpt,
    posts.content AS post_content,
    posts.url AS post_url,
    posts.image_url AS post_image_url,
    posts.published_at AS post_published_at,
    posts.like_count AS post_like_count,
    1 AS post_liked_by_user,  -- Since we are selecting from `likes`, this is always 1
    posts.reply_count AS post_reply_count,
    posts.reading_time_minutes AS post_reading_time_minutes,
    posts.attachments AS post_attachments,
    posts.repost_count AS post_repost_count,
    CASE
        WHEN reposts.post_id IS NOT NULL THEN 1
        ELSE 0
    END AS post_reposted_by_user,
    posts.ap_id AS post_ap_id,
    -- Author fields
    author_account.id AS author_id,
    author_account.name AS author_name,
    author_account.username AS author_username,
    author_account.url AS author_url,
    author_account.avatar_url AS author_avatar_url,
    -- Reposter fields
    reposter_account.id AS reposter_id,
    reposter_account.name AS reposter_name,
    reposter_account.username AS reposter_username,
    reposter_account.url AS reposter_url,
    reposter_account.avatar_url AS reposter_avatar_url
FROM likes
INNER JOIN posts ON posts.id = likes.post_id
INNER JOIN accounts AS author_account ON author_account.id = posts.author_id
LEFT JOIN reposts ON reposts.post_id = posts.id 
    AND reposts.account_id = '<accountId>'
LEFT JOIN accounts AS reposter_account ON reposter_account.id = reposts.account_id
WHERE likes.account_id = '<accountId>'
AND posts.deleted_at IS NULL
AND (likes.id < '<cursor>' OR '<cursor>' IS NULL)
ORDER BY likes.id DESC
LIMIT <limit> + 1;

```
created_at: 2025-03-13T05:37:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN likes.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
+
+    /**
+     * Get posts liked by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsLikedByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db('likes')
path: src/profile/profile.service.ts
user: login: vershwal
body: Example:-

```
SELECT 
    likes.id AS likes_id,
    -- Post fields
    posts.id AS post_id,
    posts.type AS post_type,
    posts.title AS post_title,
    posts.excerpt AS post_excerpt,
    posts.content AS post_content,
    posts.url AS post_url,
    posts.image_url AS post_image_url,
    posts.published_at AS post_published_at,
    posts.like_count AS post_like_count,
    1 AS post_liked_by_user,  -- Since we are selecting from `likes`, this is always 1
    posts.reply_count AS post_reply_count,
    posts.reading_time_minutes AS post_reading_time_minutes,
    posts.attachments AS post_attachments,
    posts.repost_count AS post_repost_count,
    CASE
        WHEN reposts.post_id IS NOT NULL THEN 1
        ELSE 0
    END AS post_reposted_by_user,
    posts.ap_id AS post_ap_id,
    -- Author fields
    author_account.id AS author_id,
    author_account.name AS author_name,
    author_account.username AS author_username,
    author_account.url AS author_url,
    author_account.avatar_url AS author_avatar_url,
    -- Reposter fields
    reposter_account.id AS reposter_id,
    reposter_account.name AS reposter_name,
    reposter_account.username AS reposter_username,
    reposter_account.url AS reposter_url,
    reposter_account.avatar_url AS reposter_avatar_url
FROM likes
INNER JOIN posts ON posts.id = likes.post_id
INNER JOIN accounts AS author_account ON author_account.id = posts.author_id
LEFT JOIN reposts ON reposts.post_id = posts.id 
    AND reposts.account_id = '2'
LEFT JOIN accounts AS reposter_account ON reposter_account.id = reposts.account_id
WHERE likes.account_id = '2'
AND posts.deleted_at IS NULL
AND (likes.id < 1000)
ORDER BY likes.id DESC
LIMIT 101
```
created_at: 2025-03-13T05:44:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN likes.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                        WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                        ELSE 0 
+                    END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
+
+    /**
+     * Get posts liked by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsLikedByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db('likes')
path: src/profile/profile.service.ts
user: login: vershwal
body: `Avg run time: 0.89 ms - 1.21 ms`

```
-> Limit: 101 row(s)  (cost=4.89 rows=1) (actual time=0.133..0.231 rows=7 loops=1)
    -> Nested loop left join  (cost=4.89 rows=1) (actual time=0.132..0.229 rows=7 loops=1)
        -> Nested loop inner join  (cost=4.65 rows=1) (actual time=0.117..0.211 rows=7 loops=1)
            -> Nested loop left join  (cost=4.40 rows=1) (actual time=0.101..0.162 rows=7 loops=1)
                -> Nested loop inner join  (cost=4.16 rows=1) (actual time=0.088..0.137 rows=7 loops=1)
                    -> Sort: likes.id DESC  (cost=0.95 rows=7) (actual time=0.052..0.052 rows=7 loops=1)
                        -> Filter: (likes.id < 1000)  (actual time=0.030..0.036 rows=7 loops=1)
                            -> Index lookup on likes using account_id (account_id=2)  (actual time=0.029..0.034 rows=7 loops=1)
                    -> Filter: (posts.deleted_at IS NULL)  (cost=0.36 rows=0.1) (actual time=0.012..0.012 rows=1 loops=7)
                        -> Single-row index lookup on posts using PRIMARY (id=likes.post_id)  (cost=0.36 rows=1) (actual time=0.011..0.011 rows=1 loops=7)
                -> Single-row covering index lookup on reposts using account_id (account_id='2', post_id=likes.post_id)  (cost=0.39 rows=1) (actual time=0.003..0.003 rows=0 loops=7)
            -> Single-row index lookup on author_account using PRIMARY (id=posts.author_id)  (cost=0.39 rows=1) (actual time=0.007..0.007 rows=1 loops=7)
        -> Single-row index lookup on reposter_account using PRIMARY (id=reposts.account_id)  (cost=0.39 rows=1) (actual time=0.002..0.002 rows=0 loops=7)

```
created_at: 2025-03-13T05:52:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
path: src/profile/profile.service.ts
user: login: vershwal
body: Example 

```
SELECT 
    -- Post fields
    posts_with_source.id AS post_id,
    posts_with_source.type AS post_type,
    posts_with_source.title AS post_title,
    posts_with_source.excerpt AS post_excerpt,
    posts_with_source.content AS post_content,
    posts_with_source.url AS post_url,
    posts_with_source.image_url AS post_image_url,
    posts_with_source.published_at AS post_published_at,
    posts_with_source.like_count AS post_like_count,
    CASE 
        WHEN likes.post_id IS NOT NULL THEN 1 
        ELSE 0 
    END AS post_liked_by_user,
    posts_with_source.reply_count AS post_reply_count,
    posts_with_source.reading_time_minutes AS post_reading_time_minutes,
    posts_with_source.attachments AS post_attachments,
    posts_with_source.repost_count AS post_repost_count,
    CASE 
        WHEN user_reposts.post_id IS NOT NULL THEN 1 
        ELSE 0 
    END AS post_reposted_by_user,
    posts_with_source.ap_id AS post_ap_id,
    -- Author fields (Who originally created the post)
    author_account.id AS author_id,
    author_account.name AS author_name,
    author_account.username AS author_username,
    author_account.url AS author_url,
    author_account.avatar_url AS author_avatar_url,
    -- Reposter fields (If applicable)
    reposter_account.id AS reposter_id,
    reposter_account.name AS reposter_name,
    reposter_account.username AS reposter_username,
    reposter_account.url AS reposter_url,
    reposter_account.avatar_url AS reposter_avatar_url,
    -- Unified `created_at` field for sorting
    posts_with_source.created_at,
    posts_with_source.deleted_at
FROM (
    -- Original posts by the account
    SELECT 
        posts.id,
        posts.type,
        posts.title,
        posts.excerpt,
        posts.content,
        posts.url,
        posts.image_url,
        posts.published_at,
        posts.like_count,
        posts.reply_count,
        posts.reading_time_minutes,
        posts.attachments,
        posts.repost_count,
        posts.ap_id,
        posts.author_id,
        posts.created_at,
        posts.deleted_at,
        NULL AS reposter_id,
        'original' AS source
    FROM posts
    WHERE posts.author_id = '2'
    
    UNION ALL
    
    -- Reposts made by the account
    SELECT 
        posts.id,
        posts.type,
        posts.title,
        posts.excerpt,
        posts.content,
        posts.url,
        posts.image_url,
        posts.published_at,
        posts.like_count,
        posts.reply_count,
        posts.reading_time_minutes,
        posts.attachments,
        posts.repost_count,
        posts.ap_id,
        posts.author_id,
        reposts.created_at AS created_at,
        posts.deleted_at,
        reposts.account_id AS reposter_id,
        'repost' AS source
    FROM reposts
    INNER JOIN posts ON posts.id = reposts.post_id
    WHERE reposts.account_id = '2'
) AS posts_with_source
-- Joining authors (post creators)
INNER JOIN accounts AS author_account ON author_account.id = posts_with_source.author_id
-- Joining reposters (if applicable)
LEFT JOIN accounts AS reposter_account ON reposter_account.id = posts_with_source.reposter_id
-- Joining likes to check if the user liked the post
LEFT JOIN likes ON likes.post_id = posts_with_source.id 
    AND likes.account_id = '2'
-- Joining reposts to check if the user reposted the post
LEFT JOIN reposts AS user_reposts ON user_reposts.post_id = posts_with_source.id 
    AND user_reposts.account_id = '2'
-- Cursor-based pagination
WHERE posts_with_source.deleted_at IS NULL
AND (posts_with_source.created_at < NOW())
ORDER BY posts_with_source.created_at DESC
LIMIT 101;
```
created_at: 2025-03-13T06:06:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,377 @@
+import type { Knex } from 'knex';
+
+import type { PostType } from 'post/post.entity';
+import { getAccountHandle } from '../account/utils';
+import type { PostDTO } from '../http/api/types';
+
+interface BaseGetProfileDataResultRow {
+    post_id: number;
+    post_type: PostType;
+    post_title: string | null;
+    post_excerpt: string | null;
+    post_content: string | null;
+    post_url: string;
+    post_image_url: string | null;
+    post_published_at: Date;
+    post_like_count: number;
+    post_liked_by_user: 0 | 1;
+    post_reply_count: number;
+    post_reading_time_minutes: number;
+    post_repost_count: number;
+    post_reposted_by_user: 0 | 1;
+    post_ap_id: string;
+    post_attachments: {
+        type: string | null;
+        mediaType: string | null;
+        name: string | null;
+        url: string;
+    }[];
+    author_id: number;
+    author_name: string | null;
+    author_username: string;
+    author_url: string | null;
+    author_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow {
+    reposter_id: number;
+    reposter_name: string | null;
+    reposter_username: string;
+    reposter_url: string | null;
+    reposter_avatar_url: string | null;
+}
+
+interface GetProfileDataResultRowWithoutReposted
+    extends BaseGetProfileDataResultRow {
+    reposter_id: null;
+    reposter_name: null;
+    reposter_username: null;
+    reposter_url: null;
+    reposter_avatar_url: null;
+}
+
+export type GetProfileDataResultRow =
+    | GetProfileDataResultRowReposted
+    | GetProfileDataResultRowWithoutReposted;
+
+export interface GetProfileDataResult {
+    results: GetProfileDataResultRow[];
+    nextCursor: string | null;
+}
+
+export class ProfileService {
+    /**
+     * @param db Database client
+     */
+    constructor(private readonly db: Knex) {}
+
+    /**
+     * Transforms a database result into a PostDTO
+     * @param result Database result row
+     * @param accountId Current account ID
+     * @returns PostDTO object
+     */
+    private mapToPostDTO(
+        result: GetProfileDataResultRow,
+        accountId: number,
+    ): PostDTO {
+        return {
+            id: result.post_ap_id,
+            type: result.post_type,
+            title: result.post_title ?? '',
+            excerpt: result.post_excerpt ?? '',
+            content: result.post_content ?? '',
+            url: result.post_url,
+            featureImageUrl: result.post_image_url ?? null,
+            publishedAt: result.post_published_at,
+            likeCount: result.post_like_count,
+            likedByMe: result.post_liked_by_user === 1,
+            replyCount: result.post_reply_count,
+            readingTimeMinutes: result.post_reading_time_minutes,
+            attachments: result.post_attachments
+                ? result.post_attachments.map((attachment) => ({
+                      type: attachment.type ?? '',
+                      mediaType: attachment.mediaType ?? '',
+                      name: attachment.name ?? '',
+                      url: attachment.url,
+                  }))
+                : [],
+            author: {
+                id: result.author_id.toString(),
+                handle: getAccountHandle(
+                    result.author_url ? new URL(result.author_url).host : '',
+                    result.author_username,
+                ),
+                name: result.author_name ?? '',
+                url: result.author_url ?? '',
+                avatarUrl: result.author_avatar_url ?? '',
+            },
+            authoredByMe: result.author_id === accountId,
+            repostCount: result.post_repost_count,
+            repostedByMe: result.post_reposted_by_user === 1,
+            repostedBy: result.reposter_id
+                ? {
+                      id: result.reposter_id.toString(),
+                      handle: getAccountHandle(
+                          result.reposter_url
+                              ? new URL(result.reposter_url).host
+                              : '',
+                          result.reposter_username,
+                      ),
+                      name: result.reposter_name ?? '',
+                      url: result.reposter_url ?? '',
+                      avatarUrl: result.reposter_avatar_url ?? '',
+                  }
+                : null,
+        };
+    }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
path: src/profile/profile.service.ts
user: login: vershwal
body: `Avg run time: 2.54 ms `

```

-> Limit: 101 row(s)  (cost=11.57 rows=0.4)  (actual time=0.457..0.513 rows=12 loops=1)
    -> Nested loop left join  (cost=11.57 rows=0.4)  (actual time=0.456..0.511 rows=12 loops=1)
        -> Nested loop left join  (cost=10.53 rows=0.4)  (actual time=0.449..0.485 rows=12 loops=1)
            -> Nested loop left join  (cost=9.48 rows=0.4)  (actual time=0.442..0.460 rows=12 loops=1)
                -> Nested loop inner join  (cost=8.44 rows=0.4)  (actual time=0.438..0.449 rows=12 loops=1)
                    -> Sort row IDs: posts_with_source.created_at DESC  (cost=7.40..7.40 rows=0.4)  (actual time=0.423..0.426 rows=12 loops=1)
                        -> Table scan on posts_with_source  (cost=7.30..7.30 rows=0.4)  (actual time=0.404..0.408 rows=12 loops=1)
                    -> Union all materialize  (cost=4.80..4.80 rows=0.4)  (actual time=0.401..0.401 rows=12 loops=1)
                        -> Filter: ((posts.deleted_at IS NULL) AND (posts.created_at < <cache>(NOW())))  (cost=4.32 rows=0.4)  (actual time=0.043..0.137 rows=11 loops=1)
                            -> Index lookup on posts using author_id (author_id=2)  (cost=4.32 rows=12)  (actual time=0.037..0.128 rows=12 loops=1)
                -> Nested loop inner join  (cost=0.44 rows=0.03)  (actual time=0.065..0.067 rows=1 loops=1)
                    -> Filter: (reposts.created_at < <cache>(NOW()))  (cost=0.28 rows=0.3)  (actual time=0.016..0.018 rows=1 loops=1)
                        -> Index lookup on reposts using account_id (account_id=2)  (cost=0.28 rows=1)  (actual time=0.015..0.017 rows=1 loops=1)
                    -> Filter: (posts.deleted_at IS NULL)  (cost=0.39 rows=0.1)  (actual time=0.047..0.048 rows=1 loops=1)
                        -> Single-row index lookup on posts using PRIMARY (id=reposts.post_id)  (cost=0.39 rows=1)  (actual time=0.046..0.047 rows=1 loops=1)
        -> Single-row index lookup on author_account using PRIMARY (id=posts_with_source.author_id)  (cost=0.28 rows=1)  (actual time=0.002..0.002 rows=1 loops=12)
        -> Single-row index lookup on reposter_account using PRIMARY (id=posts_with_source.reposter_id)  (cost=0.28 rows=1)  (actual time=0.001..0.001 rows=0 loops=12)
        -> Single-row covering index lookup on likes using account_id (account_id='2', post_id=posts_with_source.id)  (cost=0.28 rows=1)  (actual time=0.002..0.002 rows=0 loops=12)
        -> Single-row covering index lookup on user_reposts using account_id (account_id='2', post_id=posts_with_source.id)  (cost=0.28 rows=1)  (actual time=0.002..0.002 rows=0 loops=12)

```





created_at: 2025-03-13T06:16:39Z
author_association: MEMBER
diff_hunk: @@ -14,15 +14,15 @@ Feature: Delete a post
     And "OurNote" is in our Outbox
     And an authenticated request is made to "/.ghost/activitypub/feed"
     And the request is accepted
-    And the feed contains "OurNote"
-    And the feed contains "AliceNote"
+    And the "feed" response contains "OurNote"
+    And the "feed" response contains "AliceNote"
path: features/delete-post.feature
user: login: vershwal
body: Done ✅
created_at: 2025-03-13T08:06:57Z
author_association: MEMBER
diff_hunk: @@ -105,4 +225,257 @@ export class PostService {
 
         return newlyCreatedPost;
     }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                            WHEN likes.post_id IS NOT NULL THEN 1 
+                            ELSE 0 
+                        END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                            WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                            ELSE 0 
+                        END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+                'posts_with_source.deleted_at',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        'posts.deleted_at',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'posts.deleted_at',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .where('posts_with_source.deleted_at', null)
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
+
+    /**
+     * Get posts liked by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsLikedByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db('likes')
+            .select(
+                'likes.id as likes_id',
+                // Post fields
+                'posts.id as post_id',
+                'posts.type as post_type',
+                'posts.title as post_title',
+                'posts.excerpt as post_excerpt',
+                'posts.content as post_content',
+                'posts.url as post_url',
+                'posts.image_url as post_image_url',
+                'posts.published_at as post_published_at',
+                'posts.like_count as post_like_count',
+                this.db.raw('1 AS post_liked_by_user'), // Since we are selecting from `likes`, this is always 1
+                'posts.reply_count as post_reply_count',
+                'posts.reading_time_minutes as post_reading_time_minutes',
+                'posts.attachments as post_attachments',
+                'posts.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE
+                            WHEN reposts.post_id IS NOT NULL THEN 1
+                            ELSE 0
+                        END AS post_reposted_by_user`,
+                ),
+                'posts.ap_id as post_ap_id',
+                // Author fields
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+            )
+            .innerJoin('posts', 'posts.id', 'likes.post_id')
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts.author_id',
+            )
+            .leftJoin('reposts', function () {
+                this.on('reposts.post_id', 'posts.id').andOnVal(
+                    'reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'reposts.account_id',
+            )
+            .where('likes.account_id', accountId)
+            .modify((query) => {
+                if (cursor) {
+                    query.where('likes.id', '<', cursor);
+                }
+            })
+            .orderBy('likes.id', 'desc')
path: src/post/post.service.ts
user: login: vershwal
body: Removed the deleted check from this query
created_at: 2025-03-13T08:56:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,67 @@
+Feature: My Posts on Profile
+  In order to see my posts on my profile
+  As a user
+  I want to query my posts
+
+  Background:
+    Given we create a note "MyNote" with the content
+      """
+      Hello World 1
+      """
+    And We waited for 1000 milliseconds
path: features/profile-posts.feature
user: login: vershwal
body: Added this because the posts are sorted by created_at and without the wait it's causing flakiness as the posts get created at the exact same time. This would change when we'll have posts in outbox table and we can sort it by outbox.id as we do for feed and likes.
created_at: 2025-03-13T08:58:52Z
author_association: MEMBER
diff_hunk: @@ -217,3 +204,104 @@ export function createGetAccountFollowsHandler(accountService: AccountService) {
         );
     };
 }
+
+/**
+ * Validates and extracts pagination parameters from the request
+ * @param ctx App context
+ * @returns Object containing cursor and limit, or null if invalid
+ */
+function validateRequestParams(ctx: AppContext) {
+    const queryCursor = ctx.req.query('next');
+    const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;
+
+    const queryLimit = ctx.req.query('limit');
+    const limit = queryLimit ? Number(queryLimit) : DEFAULT_PROFILE_POSTS_LIMIT;
+
+    if (limit > MAX_PROFILE_POSTS_LIMIT) {
+        return null;
+    }
+
+    return { cursor, limit };
+}
+
+/**
+ * Create a handler to handle a request for a list of posts by an account
+ *
+ * @param accountService Account service instance
+ * @param profileService Profile service instance
+ */
+export function createGetAccountPostsHandler(
+    accountService: AccountService,
+    postService: PostService,
+) {
+    /**
+     * Handle a request for a list of posts by an account
+     *
+     * @param ctx App context
+     */
+    return async function handleGetPosts(ctx: AppContext) {
+        const params = validateRequestParams(ctx);
+        if (!params) {
+            return new Response(null, { status: 400 });
+        }
+
+        const account = await accountService.getDefaultAccountForSite(
+            ctx.get('site'),
+        );
+        const { results, nextCursor } = await postService.getPostsByAccount(
+            account.id,
+            params.limit,
+            params.cursor,
+        );
+
+        return new Response(
+            JSON.stringify({
+                posts: results,
+                next: nextCursor,
+            }),
+            { status: 200 },
+        );
+    };
+}
path: src/http/api/account.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add fallback for missing default account.**  
`createGetAccountPostsHandler` proceeds with fetching posts via `postService.getPostsByAccount`, assuming `account` always exists. If `getDefaultAccountForSite` returns `null`, the handler might throw or yield incomplete data. Consider returning `404` or a similar response when `account` is not found.  

```diff
const account = await accountService.getDefaultAccountForSite(ctx.get('site'));
-if (!account) {
-    return new Response(null, { status: 404 });
-}
const { results, nextCursor } = await postService.getPostsByAccount(
    account.id,
    params.limit,
    params.cursor,
);
```


> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-13T10:08:07Z
author_association: NONE
diff_hunk: @@ -105,4 +225,257 @@ export class PostService {
 
         return newlyCreatedPost;
     }
+
+    /**
+     * Get posts by an account
+     *
+     * @param accountId ID of the account to get posts for
+     * @param limit Maximum number of posts to return
+     * @param cursor Cursor to use for pagination
+     */
+    async getPostsByAccount(
+        accountId: number,
+        limit: number,
+        cursor: string | null,
+    ): Promise<GetProfileDataResult> {
+        const query = this.db
+            .select(
+                // Post fields
+                'posts_with_source.id as post_id',
+                'posts_with_source.type as post_type',
+                'posts_with_source.title as post_title',
+                'posts_with_source.excerpt as post_excerpt',
+                'posts_with_source.content as post_content',
+                'posts_with_source.url as post_url',
+                'posts_with_source.image_url as post_image_url',
+                'posts_with_source.published_at as post_published_at',
+                'posts_with_source.like_count as post_like_count',
+                this.db.raw(
+                    `CASE 
+                            WHEN likes.post_id IS NOT NULL THEN 1 
+                            ELSE 0 
+                        END AS post_liked_by_user`,
+                ),
+                'posts_with_source.reply_count as post_reply_count',
+                'posts_with_source.reading_time_minutes as post_reading_time_minutes',
+                'posts_with_source.attachments as post_attachments',
+                'posts_with_source.repost_count as post_repost_count',
+                this.db.raw(
+                    `CASE 
+                            WHEN user_reposts.post_id IS NOT NULL THEN 1 
+                            ELSE 0 
+                        END AS post_reposted_by_user`,
+                ),
+                'posts_with_source.ap_id as post_ap_id',
+                // Author fields (Who originally created the post)
+                'author_account.id as author_id',
+                'author_account.name as author_name',
+                'author_account.username as author_username',
+                'author_account.url as author_url',
+                'author_account.avatar_url as author_avatar_url',
+                // Reposter fields (If applicable)
+                'reposter_account.id as reposter_id',
+                'reposter_account.name as reposter_name',
+                'reposter_account.username as reposter_username',
+                'reposter_account.url as reposter_url',
+                'reposter_account.avatar_url as reposter_avatar_url',
+                // Unified `created_at` field for sorting
+                'posts_with_source.created_at',
+                'posts_with_source.deleted_at',
+            )
+            .from(
+                this.db
+                    .select(
+                        'posts.id',
+                        'posts.type',
+                        'posts.title',
+                        'posts.excerpt',
+                        'posts.content',
+                        'posts.url',
+                        'posts.image_url',
+                        'posts.published_at',
+                        'posts.like_count',
+                        'posts.reply_count',
+                        'posts.reading_time_minutes',
+                        'posts.attachments',
+                        'posts.repost_count',
+                        'posts.ap_id',
+                        'posts.author_id',
+                        'posts.created_at',
+                        'posts.deleted_at',
+                        this.db.raw('NULL as reposter_id'),
+                        this.db.raw(`'original' as source`),
+                    )
+                    .from('posts')
+                    .where('posts.author_id', accountId)
+                    .unionAll([
+                        this.db
+                            .select(
+                                'posts.id',
+                                'posts.type',
+                                'posts.title',
+                                'posts.excerpt',
+                                'posts.content',
+                                'posts.url',
+                                'posts.image_url',
+                                'posts.published_at',
+                                'posts.like_count',
+                                'posts.reply_count',
+                                'posts.reading_time_minutes',
+                                'posts.attachments',
+                                'posts.repost_count',
+                                'posts.ap_id',
+                                'posts.author_id',
+                                'reposts.created_at AS created_at',
+                                'posts.deleted_at',
+                                'reposts.account_id as reposter_id',
+                                this.db.raw(`'repost' as source`),
+                            )
+                            .from('reposts')
+                            .innerJoin('posts', 'posts.id', 'reposts.post_id')
+                            .where('reposts.account_id', accountId),
+                    ])
+                    .orderBy('created_at', 'desc') //Apply sorting at the union level
+                    .as('posts_with_source'),
+            )
+            .innerJoin(
+                'accounts as author_account',
+                'author_account.id',
+                'posts_with_source.author_id',
+            )
+            .leftJoin(
+                'accounts as reposter_account',
+                'reposter_account.id',
+                'posts_with_source.reposter_id',
+            )
+            .leftJoin('likes', function () {
+                this.on('likes.post_id', 'posts_with_source.id').andOnVal(
+                    'likes.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .leftJoin('reposts as user_reposts', function () {
+                this.on(
+                    'user_reposts.post_id',
+                    'posts_with_source.id',
+                ).andOnVal(
+                    'user_reposts.account_id',
+                    '=',
+                    accountId.toString(),
+                );
+            })
+            .modify((query) => {
+                if (cursor) {
+                    query.where('posts_with_source.created_at', '<', cursor);
+                }
+            })
+            .where('posts_with_source.deleted_at', null)
+            .orderBy('posts_with_source.created_at', 'desc')
+            .limit(limit + 1);
+
+        const results = await query;
+
+        const hasMore = results.length > limit;
+        const paginatedResults = results.slice(0, limit);
+        const lastResult = paginatedResults[paginatedResults.length - 1];
+
+        return {
+            results: paginatedResults.map((result: GetProfileDataResultRow) =>
+                this.mapToPostDTO(result, accountId),
+            ),
+            nextCursor: hasMore ? lastResult.created_at : null,
+        };
+    }
path: src/post/post.service.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Check large UNION queries for performance and indexing.**  
`getPostsByAccount` uses a UNION and sorts by `created_at`. For significant data volumes, ensure there's an appropriate index on `created_at`, and confirm the DB can efficiently manage these sorts.

---

🏁 Script executed:

```shell
#!/bin/bash
# Verify presence of indexes on created_at in posts and reposts
rg "CREATE\s+TABLE\s+posts" -A 40
rg "CREATE\s+TABLE\s+reposts" -A 40
```

Length of output: 6545

---



</details>

**Performance and Indexing Concern on UNION Query**  
After verifying the SQL migrations, it appears that the `created_at` columns in both the `posts` and `reposts` tables are not explicitly indexed. Since `getPostsByAccount` relies on a UNION of these tables and orders results by `created_at`, the absence of dedicated indexes could result in performance bottlenecks when handling large data volumes. Please consider adding indexes on `created_at` in both tables and confirm that the database can efficiently manage the sorting operations at scale.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-13T10:08:08Z
author_association: NONE
diff_hunk: @@ -20,18 +20,18 @@ Feature: Delete(Note)
     And "AliceNote" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "AliceNote"
-    And the feed does not contain "AliceNote"
+    And "AliceNote" is in the feed
+    And "AliceNote" is not in the feed
path: features/handle-delete.feature
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Fix logical contradiction in assertions**

Line 23 asserts that "AliceNote is in the feed" while line 24 states "AliceNote is not in the feed" - this appears to be a contradiction that will cause tests to fail.


Please review these assertions as they seem mutually exclusive. If these assertions are meant to represent different states before and after an action, an action step should be placed between them.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-13T12:06:46Z
author_association: NONE
diff_hunk: @@ -0,0 +1,67 @@
+Feature: My Posts on Profile
+  In order to see my posts on my profile
+  As a user
+  I want to query my posts
+
+  Background:
+    Given we create a note "MyNote" with the content
+      """
+      Hello World 1
+      """
+    And We waited for 1000 milliseconds
+    And we create a note "MyNote2" with the content
+      """
+      Hello World 2
+      """
+    And We waited for 1000 milliseconds
+    And we are following "Alice"
+
+  Scenario: Querying profile posts
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "MyNote" is in the posts
+    And "MyNote2" is in the posts
+
+  Scenario: My posts does not contain posts from followed accounts
+    Given a "Create(Note)" Activity "AliceNote" by "Alice"
+    And "Alice" sends "AliceNote" to the Inbox
+    And "AliceNote" is in our Inbox
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "MyNote" is in the posts
+    And "MyNote2" is in the posts
+    And "AliceNote" is not in the posts
+
+  Scenario: Profile posts includes posts we reposted
+    Given a "Create(Note)" Activity "AliceNote" by "Alice"
+    And "Alice" sends "AliceNote" to the Inbox
+    And "AliceNote" is in our Inbox
+    And we repost the object "AliceNote"
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "AliceNote" is in the posts
+
+  @only
+  Scenario: Liked posts are sorted by date descending
path: features/profile-posts.feature
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Remove @only tags before merging.**

The @only tags will restrict test execution to only these scenarios. This is useful during development, but should be removed before merging to ensure all tests run in the CI pipeline.


```diff
-  @only
   Scenario: Liked posts are sorted by date descending
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  Scenario: Liked posts are sorted by date descending
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-13T12:06:46Z
author_association: NONE
diff_hunk: @@ -0,0 +1,67 @@
+Feature: My Posts on Profile
+  In order to see my posts on my profile
+  As a user
+  I want to query my posts
+
+  Background:
+    Given we create a note "MyNote" with the content
+      """
+      Hello World 1
+      """
+    And We waited for 1000 milliseconds
+    And we create a note "MyNote2" with the content
+      """
+      Hello World 2
+      """
+    And We waited for 1000 milliseconds
+    And we are following "Alice"
+
+  Scenario: Querying profile posts
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "MyNote" is in the posts
+    And "MyNote2" is in the posts
+
+  Scenario: My posts does not contain posts from followed accounts
+    Given a "Create(Note)" Activity "AliceNote" by "Alice"
+    And "Alice" sends "AliceNote" to the Inbox
+    And "AliceNote" is in our Inbox
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "MyNote" is in the posts
+    And "MyNote2" is in the posts
+    And "AliceNote" is not in the posts
+
+  Scenario: Profile posts includes posts we reposted
+    Given a "Create(Note)" Activity "AliceNote" by "Alice"
+    And "Alice" sends "AliceNote" to the Inbox
+    And "AliceNote" is in our Inbox
+    And we repost the object "AliceNote"
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And "AliceNote" is in the posts
+
+  @only
+  Scenario: Liked posts are sorted by date descending
+    When an authenticated request is made to "/.ghost/activitypub/posts"
+    Then the request is accepted
+    And post "1" in the "posts" response is "MyNote2"
+    And post "2" in the "posts" response is "MyNote"
+
+  @only
+  Scenario: Liked posts are paginated
path: features/profile-posts.feature
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Remove @only tags before merging.**

Similarly, this @only tag should be removed before merging.


```diff
-  @only
   Scenario: Liked posts are paginated
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  Scenario: Liked posts are paginated
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-13T12:06:46Z
author_association: NONE
diff_hunk: @@ -217,3 +204,104 @@ export function createGetAccountFollowsHandler(accountService: AccountService) {
         );
     };
 }
+
+/**
+ * Validates and extracts pagination parameters from the request
+ * @param ctx App context
+ * @returns Object containing cursor and limit, or null if invalid
+ */
+function validateRequestParams(ctx: AppContext) {
+    const queryCursor = ctx.req.query('next');
+    const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;
+
+    const queryLimit = ctx.req.query('limit');
+    const limit = queryLimit ? Number(queryLimit) : DEFAULT_PROFILE_POSTS_LIMIT;
+
+    if (limit > MAX_PROFILE_POSTS_LIMIT) {
+        return null;
+    }
+
+    return { cursor, limit };
+}
+
path: src/http/api/account.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Consider validating numeric input for `limit`.**

While this function correctly bounds the maximum limit, there is no explicit check that `limit` is a positive integer. If a large negative or non-numeric value is passed, the function will silently default to `DEFAULT_PROFILE_POSTS_LIMIT` or potentially yield unexpected behavior. Consider adding numeric checks and returning `null` (thus responding with a 400) if the provided `limit` is invalid.

```diff
 function validateRequestParams(ctx: AppContext) {
     const queryCursor = ctx.req.query('next');
     const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;

     const queryLimit = ctx.req.query('limit');
     const limit = queryLimit ? Number(queryLimit) : DEFAULT_PROFILE_POSTS_LIMIT;

+    if (!Number.isInteger(limit) || limit <= 0) {
+        return null; // or handle gracefully
+    }
     if (limit > MAX_PROFILE_POSTS_LIMIT) {
         return null;
     }

     return { cursor, limit };
 }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
/**
 * Validates and extracts pagination parameters from the request
 * @param ctx App context
 * @returns Object containing cursor and limit, or null if invalid
 */
function validateRequestParams(ctx: AppContext) {
    const queryCursor = ctx.req.query('next');
    const cursor = queryCursor ? decodeURIComponent(queryCursor) : null;

    const queryLimit = ctx.req.query('limit');
    const limit = queryLimit ? Number(queryLimit) : DEFAULT_PROFILE_POSTS_LIMIT;

    if (!Number.isInteger(limit) || limit <= 0) {
        return null; // or handle gracefully
    }
    if (limit > MAX_PROFILE_POSTS_LIMIT) {
        return null;
    }

    return { cursor, limit };
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-13T12:06:47Z
author_association: NONE
diff_hunk: @@ -0,0 +1,67 @@
+Feature: My Posts on Profile
+  In order to see my posts on my profile
+  As a user
+  I want to query my posts
+
+  Background:
+    Given we create a note "MyNote" with the content
+      """
+      Hello World 1
+      """
+    And We waited for 1000 milliseconds
path: features/profile-posts.feature
user: login: mike182uk
body: Hm, is there a 2nd parameter we could add to the sort, `id` maybe?
created_at: 2025-03-13T13:41:12Z
author_association: MEMBER
diff_hunk: @@ -20,18 +20,18 @@ Feature: Delete(Note)
     And "AliceNote" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "AliceNote"
-    And the feed does not contain "AliceNote"
+    And "AliceNote" is in the feed
+    And "AliceNote" is not in the feed
path: features/handle-delete.feature
user: login: mike182uk
body: hold up @vershwal whats happening here? the rabbit is making a good point
created_at: 2025-03-13T13:43:02Z
author_association: MEMBER
diff_hunk: @@ -12,34 +11,22 @@ import type { AccountDTO } from './types';
 const FOLLOWS_LIMIT = 20;
 
 /**
- * Follow account shape - Used when returning a list of follow accounts
+ * Default number of posts to return in a profile
  */
-type FollowAccount = Pick<
-    AccountDTO,
-    'id' | 'name' | 'handle' | 'avatarUrl'
-> & { isFollowing: boolean };
+const DEFAULT_PROFILE_POSTS_LIMIT = 20;
path: src/http/api/account.ts
user: login: mike182uk
body: just `DEFAULT_POSTS_LIMIT` maybe?
created_at: 2025-03-13T13:44:08Z
author_association: MEMBER
diff_hunk: @@ -12,34 +11,22 @@ import type { AccountDTO } from './types';
 const FOLLOWS_LIMIT = 20;
 
 /**
- * Follow account shape - Used when returning a list of follow accounts
+ * Default number of posts to return in a profile
  */
-type FollowAccount = Pick<
-    AccountDTO,
-    'id' | 'name' | 'handle' | 'avatarUrl'
-> & { isFollowing: boolean };
+const DEFAULT_PROFILE_POSTS_LIMIT = 20;
 
 /**
- * Retreive the count of posts created by the account from the database
- *
- * @param db Database instance
+ * Maximum number of posts that can be returned in a profile
  */
-async function getPostCount(db: KvStore) {
-    const posts = await db.get<string[]>(['outbox']);
-
-    return posts?.length || 0;
-}
+const MAX_PROFILE_POSTS_LIMIT = 100;
path: src/http/api/account.ts
user: login: mike182uk
body: just `MAX_POSTS_LIMIT` maybe?
created_at: 2025-03-13T13:44:34Z
author_association: MEMBER
diff_hunk: @@ -92,8 +79,8 @@ export function createGetAccountHandler(accountService: AccountService) {
                  * At the moment we don't support custom fields for Ghost accounts
                  */
                 customFields: {},
-                postCount: await getPostCount(db),
-                likedCount: await getLikedCount(db),
+                postCount: await accountService.getPostCount(account),
+                likedCount: await accountService.getLikedCount(account),
path: src/http/api/account.ts
user: login: mike182uk
body: 🙌
created_at: 2025-03-13T13:44:58Z
author_association: MEMBER
diff_hunk: @@ -217,3 +204,104 @@ export function createGetAccountFollowsHandler(accountService: AccountService) {
         );
     };
 }
+
+/**
+ * Validates and extracts pagination parameters from the request
+ * @param ctx App context
path: src/http/api/account.ts
user: login: mike182uk
body: new line above this pls 😊
created_at: 2025-03-13T13:46:47Z
author_association: MEMBER
diff_hunk: @@ -20,18 +20,18 @@ Feature: Delete(Note)
     And "AliceNote" is in our Inbox
     When an authenticated request is made to "/.ghost/activitypub/feed"
     Then the request is accepted
-    And the feed contains "AliceNote"
-    And the feed does not contain "AliceNote"
+    And "AliceNote" is in the feed
+    And "AliceNote" is not in the feed
path: features/handle-delete.feature
user: login: coderabbitai[bot]
body: > Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

<!-- This is an auto-generated mute comment by CodeRabbit -->
created_at: 2025-03-13T13:47:56Z
author_association: NONE
diff_hunk: @@ -0,0 +1,67 @@
+Feature: My Posts on Profile
+  In order to see my posts on my profile
+  As a user
+  I want to query my posts
+
+  Background:
+    Given we create a note "MyNote" with the content
+      """
+      Hello World 1
+      """
+    And We waited for 1000 milliseconds
path: features/profile-posts.feature
user: login: vershwal
body: It does make sense to use something like an `id` as a sorting parameter, but since the final result is a union of posts and reposts, there’s no common id to sort on.

We’ll be updating the query in the coming weeks anyway, so I’ll skip adding this complexity for now.
created_at: 2025-03-13T14:07:37Z
author_association: MEMBER

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-03-13T12:02:36Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

The changes introduce updates across multiple layers of the system. Feature files have been modified to rephrase assertions for clarity in testing operations such as post deletion, feed validation, and delete handling. New feature files have been added to cover scenarios for querying liked posts and user posts on profiles, including cases for sorting and pagination. The step definitions have been enhanced with regex-based matching for assertions and a new step for adding delays. In the service layer, additional methods have been added to count liked posts and created posts. Route handlers have been introduced to handle requests for fetching account posts and liked posts with the necessary middleware protections, and API handlers now include pagination and parameter validation. The post service has been expanded with new interfaces and methods to handle data retrieval, mapping, and pagination, ensuring structured responses for various post queries.

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡🧪 Multi-step agentic review comment chat (experimental)</summary>
> 
> - We're introducing multi-step agentic chat in review comments. This experimental feature enhances review discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments.
> 						- To enable this feature, set early_access to true under in the settings.
> 
> </details>

<!-- announcements_end -->
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=TryGhost/ActivityPub&utm_content=382):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE