diff --git a/.gitignore b/.gitignore index c40cc4d..997da86 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,6 @@ yarn.lock *.DS_Store *.pem firebase-admin-service-account.json +dumps/ # Firebase secrets secrets/ diff --git a/jest.config.js b/jest.config.js index 6eff78f..832b34b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,4 +4,7 @@ module.exports = { roots: ["src"], maxWorkers: 1, verbose: true, + moduleNameMapper: { + "^firebase-admin/(.*)$": "/node_modules/firebase-admin/lib/$1/index.js", + }, }; diff --git a/src/api/controllers/EventController.ts b/src/api/controllers/EventController.ts new file mode 100644 index 0000000..bfbddd6 --- /dev/null +++ b/src/api/controllers/EventController.ts @@ -0,0 +1,39 @@ +import { + CurrentUser, + Get, + JsonController, + Param, + QueryParam, +} from "routing-controllers"; + +import { UserModel } from "../../models/UserModel"; +import { EventTagModel } from "../../models/EventTagModel"; +import { EventService } from "../../services/EventService"; +import { EventPostSource, GetEventPostsResponse } from "../../types"; + +@JsonController("event/") +export class EventController { + private eventService: EventService; + + constructor(eventService: EventService) { + this.eventService = eventService; + } + + @Get("available-for-tagging/") + async getAvailableEventTags( + @CurrentUser() user: UserModel, + ): Promise { + return this.eventService.getAvailableEventTags(); + } + + @Get(":eventTagId/posts/") + async getEventPosts( + @CurrentUser() user: UserModel, + @Param("eventTagId") eventTagId: string, + @QueryParam("page", { required: false }) page: number = 1, + @QueryParam("limit", { required: false }) limit: number = 10, + @QueryParam("source", { required: false }) source?: EventPostSource, + ): Promise { + return this.eventService.getEventPosts(user, eventTagId, page, limit, source); + } +} diff --git a/src/api/controllers/index.ts b/src/api/controllers/index.ts index 405e0e8..bacb9db 100644 --- a/src/api/controllers/index.ts +++ b/src/api/controllers/index.ts @@ -1,5 +1,6 @@ import { AuthController } from './AuthController'; import { AvailabilityController } from './AvailabilityController'; +import { EventController } from './EventController'; import { FeedbackController } from './FeedbackController'; import { ImageController } from './ImageController'; import { PostController } from './PostController'; @@ -19,6 +20,7 @@ export const controllers = [ AuthTokenController, AvailabilityController, ChatController, + EventController, FeedbackController, ImageController, NotifController, diff --git a/src/app.ts b/src/app.ts index 8f2d2e3..8e89ac2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,6 +26,7 @@ import resellConnection from './utils/DB'; import { ReportService } from './services/ReportService'; import { reportToString } from './utils/Requests'; import { startTransactionConfirmationCron } from './cron/transactionCron'; +import { startEventSimilarityCron } from './cron/eventSimilarityCron'; // Setup dependency injection containers routingUseContainer(Container); @@ -199,6 +200,7 @@ async function main() { console.log(`Resell backend bartering on http://localhost:${port}`); startTransactionConfirmationCron(); + startEventSimilarityCron(); }); } diff --git a/src/cron/eventSimilarityCron.ts b/src/cron/eventSimilarityCron.ts new file mode 100644 index 0000000..e7e5456 --- /dev/null +++ b/src/cron/eventSimilarityCron.ts @@ -0,0 +1,43 @@ +import cron from 'node-cron'; +import { getManager } from 'typeorm'; +import { EventPostService } from '../services/EventPostService'; +import { EventTagRepository } from '../repositories/EventTagRepository'; + +/** + * Hourly cron that reprocesses similarity for every event tag. + * For each tag it computes a centroid from user-tagged post embeddings + * and upserts similar posts into the eventPosts table. + */ +export function startEventSimilarityCron() { + cron.schedule('0 * * * *', async () => { + console.log('[CRON] Starting event similarity processing...'); + + try { + const entityManager = getManager(); + const eventTagRepo = entityManager.getCustomRepository(EventTagRepository); + const eventTags = await eventTagRepo.getAllEventTags(); + + if (eventTags.length === 0) { + console.log('[CRON] No event tags found, skipping similarity processing.'); + return; + } + + const eventPostService = new EventPostService(entityManager); + + for (const tag of eventTags) { + try { + await eventPostService.processEventSimilarity(tag.id); + console.log(`[CRON] Similarity processed for event "${tag.name}" (${tag.id})`); + } catch (err) { + console.error(`[CRON] Error processing similarity for event "${tag.name}" (${tag.id}):`, err); + } + } + + console.log(`[CRON] Event similarity processing complete for ${eventTags.length} event(s).`); + } catch (error) { + console.error('[CRON] Fatal error in event similarity cron:', error); + } + }); + + console.log('[CRON] Event similarity cron job started (runs every hour at :00)'); +} diff --git a/src/migrations/1769700000000-CreateEventPost.ts b/src/migrations/1769700000000-CreateEventPost.ts new file mode 100644 index 0000000..d614984 --- /dev/null +++ b/src/migrations/1769700000000-CreateEventPost.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateEventPost1769700000000 implements MigrationInterface { + name = "CreateEventPost1769700000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "eventPosts" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "postId" uuid NOT NULL, + "eventTagId" uuid NOT NULL, + "source" character varying(20) NOT NULL, + "relevanceScore" float DEFAULT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_eventPosts" PRIMARY KEY ("id"), + CONSTRAINT "UQ_eventPost_postId_eventTagId" UNIQUE ("postId", "eventTagId"), + CONSTRAINT "FK_eventPost_postId" FOREIGN KEY ("postId") + REFERENCES "Post"("id") ON DELETE CASCADE, + CONSTRAINT "FK_eventPost_eventTagId" FOREIGN KEY ("eventTagId") + REFERENCES "EventTag"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query(` + CREATE INDEX "IDX_eventPost_eventTagId" ON "eventPosts" ("eventTagId") + `); + + await queryRunner.query(` + CREATE INDEX "IDX_eventPost_postId" ON "eventPosts" ("postId") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_eventPost_postId"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_eventPost_eventTagId"`); + await queryRunner.query(`DROP TABLE IF EXISTS "eventPosts"`); + } +} diff --git a/src/models/EventPostModel.ts b/src/models/EventPostModel.ts new file mode 100644 index 0000000..343a93a --- /dev/null +++ b/src/models/EventPostModel.ts @@ -0,0 +1,49 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from "typeorm"; +import { Uuid } from "../types"; +import { PostModel } from "./PostModel"; +import { EventTagModel } from "./EventTagModel"; + +export type EventPostSource = "user" | "similarity" | "nlp_context"; + +@Entity("eventPosts") +// Enforces one row per post per event, so ML layer can't insert another row if event already user-tagged +@Unique(["postId", "eventTagId"]) +export class EventPostModel { + @PrimaryGeneratedColumn("uuid") + id: Uuid; + + @Column() + postId: Uuid; + + @ManyToOne(() => PostModel, { onDelete: "CASCADE" }) + @JoinColumn({ name: "postId" }) + post: PostModel; + + @Column() + eventTagId: Uuid; + + @ManyToOne(() => EventTagModel, { onDelete: "CASCADE" }) + @JoinColumn({ name: "eventTagId" }) + eventTag: EventTagModel; + + @Column({ type: "varchar", length: 20 }) + source: EventPostSource; + + @Column({ type: "float", nullable: true, default: null }) + relevanceScore: number | null; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt: Date; + + @UpdateDateColumn({ type: "timestamptz" }) + updatedAt: Date; +} diff --git a/src/models/EventTagModel.ts b/src/models/EventTagModel.ts index 7ae938e..06b5f30 100644 --- a/src/models/EventTagModel.ts +++ b/src/models/EventTagModel.ts @@ -14,7 +14,7 @@ export class EventTagModel { @Column() name: string; - + @ManyToMany(() => PostModel, (post) => post.eventTags) posts: PostModel[]; diff --git a/src/models/index.ts b/src/models/index.ts index 9c5c598..a114af3 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -12,6 +12,7 @@ import { FcmTokenModel } from "./FcmTokenModel"; import { CategoryModel } from "./CategoryModel"; import { SearchModel } from "./SearchModel"; import { EventTagModel } from "./EventTagModel"; +import { EventPostModel } from "./EventPostModel"; export const models = [ FeedbackModel, @@ -28,4 +29,5 @@ export const models = [ CategoryModel, SearchModel, EventTagModel, + EventPostModel, ]; diff --git a/src/repositories/EventPostRepository.ts b/src/repositories/EventPostRepository.ts new file mode 100644 index 0000000..ca3e4ea --- /dev/null +++ b/src/repositories/EventPostRepository.ts @@ -0,0 +1,154 @@ +import { AbstractRepository, EntityRepository } from 'typeorm'; + +import { EventPostModel, EventPostSource } from '../models/EventPostModel'; + +@EntityRepository(EventPostModel) +export class EventPostRepository extends AbstractRepository { + + /** + * Create or update a post-event relationship. + */ + public async upsertRelationship( + postId: string, + eventTagId: string, + source: EventPostSource, + relevanceScore: number | null, + ): Promise { + const existing = await this.repository + .createQueryBuilder("epr") + .where("epr.postId = :postId", { postId }) + .andWhere("epr.eventTagId = :eventTagId", { eventTagId }) + .getOne(); + + // User source takes priority (an existing user tag is never overwritten by an ML source) + if (existing) { + if (existing.source === 'user' && source !== 'user') { + return existing; + } + existing.source = source; + existing.relevanceScore = relevanceScore; + return await this.repository.save(existing); + } + + const relationship = this.repository.create({ postId, eventTagId, source, relevanceScore }); + return await this.repository.save(relationship); + } + + /** + * Get all user-tagged relationships for an event, with the post (including embedding) loaded. + * Used as anchor points for similarity processing. + */ + public async getUserTaggedPostsForEvent(eventTagId: string): Promise { + return await this.repository + .createQueryBuilder("epr") + .leftJoinAndSelect("epr.post", "post") + .where("epr.eventTagId = :eventTagId", { eventTagId }) + .andWhere("epr.source = 'user'") + .getMany(); + } + + /** + * Delete all relationships for an event matching a given source. + * Called before reprocessing to clear stale ML results. + */ + public async deleteRelationshipsBySourceForEvent( + eventTagId: string, + source: EventPostSource, + ): Promise { + await this.repository.delete({ eventTagId, source }); + } + + /** + * Delete a specific post-event relationship. + * Called when a user removes an event tag from their post. + */ + public async deleteRelationship(postId: string, eventTagId: string): Promise { + await this.repository.delete({ postId, eventTagId }); + } + + /** + * Get paginated posts for an event, optionally filtered by source. + * Ordered by layer priority: user-tagged (by post recency) first, + * then similarity (by relevance score). + */ + private static readonly SOURCE_PRIORITY_EXPR = + `CASE "epr"."source" WHEN 'user' THEN 0 WHEN 'similarity' THEN 1 ELSE 2 END`; + private static readonly SCORE_OR_RECENCY_EXPR = + `CASE WHEN "epr"."source" = 'user' THEN EXTRACT(EPOCH FROM "post"."created") ELSE "epr"."relevanceScore" END`; + + public async getPostsForEvent( + eventTagId: string, + requestingUserId: string, + source?: EventPostSource, + skip: number = 0, + limit: number = 10, + ): Promise { + const qb = this.repository + .createQueryBuilder("epr") + .select("epr.id") + .addSelect(EventPostRepository.SOURCE_PRIORITY_EXPR, "source_priority") + .addSelect(EventPostRepository.SCORE_OR_RECENCY_EXPR, "score_or_recency") + .innerJoin("epr.post", "post") + .innerJoin("post.user", "author") + .where("epr.eventTagId = :eventTagId", { eventTagId }) + .andWhere("author.isActive = true") + .andWhere( + `author.firebaseUid NOT IN (SELECT "blocking" FROM "userBlockingUsers" WHERE "blockers" = :requestingUserId)`, + { requestingUserId }, + ); + + if (source) { + qb.andWhere("epr.source = :source", { source }); + } + + qb + .orderBy("source_priority", "ASC") + .addOrderBy("score_or_recency", "DESC", "NULLS LAST") + .skip(skip) + .take(limit); + + const eprIds = await qb.getMany(); + const ids = eprIds.map((e) => e.id); + if (ids.length === 0) return []; + + return await this.repository + .createQueryBuilder("epr") + .leftJoinAndSelect("epr.post", "post") + .leftJoinAndSelect("post.user", "user") + .leftJoinAndSelect("post.categories", "categories") + .leftJoinAndSelect("post.eventTags", "eventTags") + .addSelect(EventPostRepository.SOURCE_PRIORITY_EXPR, "source_priority") + .addSelect(EventPostRepository.SCORE_OR_RECENCY_EXPR, "score_or_recency") + .where("epr.id IN (:...ids)", { ids }) + .orderBy("source_priority", "ASC") + .addOrderBy("score_or_recency", "DESC", "NULLS LAST") + .getMany(); + } + + /** + * Count posts for an event, optionally filtered by source. + * Used for pagination totals. + */ + public async getPostCountForEvent( + eventTagId: string, + requestingUserId: string, + source?: EventPostSource, + ): Promise { + const qb = this.repository + .createQueryBuilder("epr") + .innerJoin("epr.post", "post") + .innerJoin("post.user", "author") + .where("epr.eventTagId = :eventTagId", { eventTagId }) + .andWhere("author.isActive = true") + .andWhere( + `author.firebaseUid NOT IN (SELECT "blocking" FROM "userBlockingUsers" WHERE "blockers" = :requestingUserId)`, + { requestingUserId }, + ); + + if (source) { + qb.andWhere("epr.source = :source", { source }); + } + + return await qb.getCount(); + } +} diff --git a/src/repositories/EventTagRepository.ts b/src/repositories/EventTagRepository.ts index c7495fc..d8797ff 100644 --- a/src/repositories/EventTagRepository.ts +++ b/src/repositories/EventTagRepository.ts @@ -36,4 +36,11 @@ export class EventTagRepository extends AbstractRepository { const eventTag = this.repository.create({ name }); return await this.repository.save(eventTag); } + + public async getAllEventTags(): Promise { + return await this.repository + .createQueryBuilder("eventTag") + .orderBy("eventTag.name", "ASC") + .getMany(); + } } diff --git a/src/repositories/PostRepository.ts b/src/repositories/PostRepository.ts index 4ad5d53..45270dd 100644 --- a/src/repositories/PostRepository.ts +++ b/src/repositories/PostRepository.ts @@ -522,6 +522,8 @@ export class PostRepository extends AbstractRepository { .createQueryBuilder("post") .leftJoinAndSelect("post.user", "user") .where("post.id != :excludePostId", { excludePostId }) + .andWhere("post.embedding IS NOT NULL") + .andWhere("array_length(post.embedding, 1) = 512") .andWhere("user.firebaseUid != :excludeUserId", { excludeUserId }) .orderBy(`embedding::vector <-> CAST('${lit}' AS vector(512))`) .setParameters({ embedding: queryEmbedding }) @@ -542,12 +544,64 @@ export class PostRepository extends AbstractRepository { .createQueryBuilder("post") .leftJoinAndSelect("post.user", "user") .where("post.embedding IS NOT NULL") + .andWhere("array_length(post.embedding, 1) = 512") .andWhere("user.firebaseUid != :excludeUserId", { excludeUserId }) .orderBy(`post.embedding::vector <-> CAST('${lit}' AS vector(512))`) .limit(limit) .getMany(); } + /* + This method is for finding posts similar to a centroid embedding for an event. + Returns posts with their similarity score, ordered by score descending. + */ + public async findSimilarPostsForEvent( + centroidEmbedding: number[], + excludePostIds: string[], + threshold: number, + limit: number = 100, + ): Promise<{ post: PostModel; score: number }[]> { + const lit = `[${centroidEmbedding.join(",")}]`; + const scoreExpr = `(1 - (post.embedding::vector <=> CAST('${lit}' AS vector(512))))`; + + // get IDs and scores with threshold filter + pagination + const qb = this.repository + .createQueryBuilder("post") + .select("post.id", "id") + .addSelect(scoreExpr, "score") + .where("post.embedding IS NOT NULL") + .andWhere("array_length(post.embedding, 1) = 512") + .andWhere("post.archive = false") + .andWhere("post.sold = false") + .andWhere(`${scoreExpr} >= :threshold`, { threshold }); + + if (excludePostIds.length > 0) { + qb.andWhere("post.id NOT IN (:...excludePostIds)", { excludePostIds }); + } + + qb.orderBy("score", "DESC").limit(limit); + + const rawResults: { id: string; score: string }[] = await qb.getRawMany(); + if (rawResults.length === 0) return []; + + const ids = rawResults.map((r) => r.id); + const scoreMap = new Map(rawResults.map((r) => [r.id, Number(r.score)])); + + // fetch full posts with all relations + const posts = await this.repository + .createQueryBuilder("post") + .leftJoinAndSelect("post.user", "user") + .leftJoinAndSelect("post.categories", "categories") + .leftJoinAndSelect("post.eventTags", "eventTags") + .where("post.id IN (:...ids)", { ids }) + .getMany(); + + // reattach scores and restore score ordering + return posts + .map((post) => ({ post, score: scoreMap.get(post.id) ?? 0 })) + .sort((a, b) => b.score - a.score); + } + public async getSuggestedPosts( userId: string, limit = 10, @@ -662,6 +716,7 @@ export class PostRepository extends AbstractRepository { .createQueryBuilder("post") .leftJoinAndSelect("post.user", "user") .where("post.embedding IS NOT NULL") + .andWhere("array_length(post.embedding, 1) = 512") .andWhere("post.archive = false") .andWhere("post.sold = false") .andWhere("user.firebaseUid != :excludeUserId", { excludeUserId }) diff --git a/src/repositories/RequestRepository.ts b/src/repositories/RequestRepository.ts index d72dfc4..db1e8e0 100644 --- a/src/repositories/RequestRepository.ts +++ b/src/repositories/RequestRepository.ts @@ -128,6 +128,7 @@ export class RequestRepository extends AbstractRepository { "distance", ) .where("request.embedding IS NOT NULL") + .andWhere("array_length(request.embedding, 1) = 512") .andWhere("user.firebaseUid != :excludeUserId", { excludeUserId }) // 3. Safely pass the embedding string as a parameter .setParameter("embedding", embeddingString) diff --git a/src/repositories/index.ts b/src/repositories/index.ts index a2360cf..c3b3e20 100644 --- a/src/repositories/index.ts +++ b/src/repositories/index.ts @@ -12,6 +12,7 @@ import { NotifRepository } from "./NotifRepository"; import { FcmTokenRepository } from "./FcmTokenRepository"; import { CategoryRepository } from "./CategoryRepository" import { EventTagRepository } from "./EventTagRepository"; +import { EventPostRepository } from "./EventPostRepository"; import { SearchRepository } from "./SearchRepository"; export default class Repositories { @@ -91,6 +92,12 @@ export default class Repositories { return transactionalEntityManager.getCustomRepository(FcmTokenRepository); } + public static eventPost( + transactionalEntityManager: EntityManager, + ): EventPostRepository { + return transactionalEntityManager.getCustomRepository(EventPostRepository); + } + public static search( transactionalEntityManager: EntityManager, ): SearchRepository { diff --git a/src/services/EventPostService.ts b/src/services/EventPostService.ts new file mode 100644 index 0000000..5ae67f7 --- /dev/null +++ b/src/services/EventPostService.ts @@ -0,0 +1,99 @@ +import { Service } from "typedi"; +import { EntityManager } from "typeorm"; +import { InjectManager } from "typeorm-typedi-extensions"; + +import Repositories, { TransactionsManager } from "../repositories"; + +@Service() +export class EventPostService { + readonly SIMILARITY_THRESHOLD = 0.7; + readonly MAX_RESULTS = 100; + + private transactions: TransactionsManager; + + constructor(@InjectManager() entityManager: EntityManager) { + this.transactions = new TransactionsManager(entityManager); + } + + /** + * Finds posts similar to the user-tagged posts for an event and saves them + * as similarity relationships. Clears stale similarity rows before reprocessing. + * + * Returns early if no user-tagged posts exist or none have embeddings. + */ + public async processEventSimilarity(eventTagId: string): Promise { + if (process.env.NODE_ENV === "test") return; + + await this.transactions.readWrite(async (transactionalEntityManager) => { + const eventPostRepo = Repositories.eventPost(transactionalEntityManager); + const postRepo = Repositories.post(transactionalEntityManager); + + // Get all user-tagged posts for the event (with post + embedding loaded) + const userTaggedRelationships = + await eventPostRepo.getUserTaggedPostsForEvent(eventTagId); + + // Return early if no user-tagged posts + if (userTaggedRelationships.length === 0) return; + + // Filter to posts that have embeddings + const postsWithEmbeddings = userTaggedRelationships + .filter( + (r) => + r.post?.embedding != null && + Array.isArray(r.post.embedding) && + r.post.embedding.length > 0, + ) + .map((r) => r.post.embedding as number[]); + + // Return early if no embeddings available + if (postsWithEmbeddings.length === 0) return; + + // Compute centroid embedding + const centroid = this.computeCentroid(postsWithEmbeddings); + + const userTaggedPostIds = userTaggedRelationships.map((r) => r.postId); + + // Find similar posts via pgvector cosine distance + const similarPosts = await postRepo.findSimilarPostsForEvent( + centroid, + userTaggedPostIds, + this.SIMILARITY_THRESHOLD, + this.MAX_RESULTS, + ); + + // Clear stale similarity rows before saving new results + await eventPostRepo.deleteRelationshipsBySourceForEvent( + eventTagId, + "similarity", + ); + + // Save new similarity relationships + for (const { post, score } of similarPosts) { + await eventPostRepo.upsertRelationship( + post.id, + eventTagId, + "similarity", + score, + ); + } + }); + } + + /** + * Computes the element-wise mean of a list of embeddings. + * The resulting centroid represents the average semantic direction + * of all user-tagged posts for an event. + */ + private computeCentroid(embeddings: number[][]): number[] { + const dim = embeddings[0].length; + const centroid = new Array(dim).fill(0); + + for (const embedding of embeddings) { + for (let i = 0; i < dim; i++) { + centroid[i] += embedding[i]; + } + } + + return centroid.map((v) => v / embeddings.length); + } +} diff --git a/src/services/EventService.ts b/src/services/EventService.ts new file mode 100644 index 0000000..1c99a34 --- /dev/null +++ b/src/services/EventService.ts @@ -0,0 +1,67 @@ +import { NotFoundError } from "routing-controllers"; +import { Service } from "typedi"; +import { EntityManager } from "typeorm"; +import { InjectManager } from "typeorm-typedi-extensions"; + +import { UserModel } from "../models/UserModel"; +import { EventTagModel } from "../models/EventTagModel"; +import { EventPostSource } from "../models/EventPostModel"; +import Repositories, { TransactionsManager } from "../repositories"; +import { GetEventPostsResponse, PostWithSource } from "../types"; + +@Service() +export class EventService { + private transactions: TransactionsManager; + + constructor(@InjectManager() entityManager: EntityManager) { + this.transactions = new TransactionsManager(entityManager); + } + + /** + * Returns paginated posts for an event, combining user-tagged and similarity layers. + * Ordering: user-tagged (by recency) first, then similarity (by relevance score). + * Optionally filtered to a single source layer. + */ + public async getEventPosts( + user: UserModel, + eventTagId: string, + page: number = 1, + limit: number = 10, + source?: EventPostSource, + ): Promise { + return this.transactions.readOnly(async (transactionalEntityManager) => { + const eventTagRepo = Repositories.eventTag(transactionalEntityManager); + const eventPostRepo = Repositories.eventPost(transactionalEntityManager); + + // Validate event tag exists + const matches = await eventTagRepo.findByIds([eventTagId]); + if (matches.length === 0) throw new NotFoundError('Event not found!'); + + const skip = (page - 1) * limit; + + const [relationships, total] = await Promise.all([ + eventPostRepo.getPostsForEvent(eventTagId, user.firebaseUid, source, skip, limit), + eventPostRepo.getPostCountForEvent(eventTagId, user.firebaseUid, source), + ]); + + const posts: PostWithSource[] = relationships.map(r => + Object.assign(r.post, { + source: r.source, + relevanceScore: r.relevanceScore, + }), + ); + + return { posts, total, page, limit }; + }); + } + + /** + * Returns all event tags available for user tagging. + * Future: filter by isActive or displayStartDate once activation logic is added. + */ + public async getAvailableEventTags(): Promise { + return this.transactions.readOnly(async (transactionalEntityManager) => { + return Repositories.eventTag(transactionalEntityManager).getAllEventTags(); + }); + } +} diff --git a/src/services/PostService.ts b/src/services/PostService.ts index bf2df6a..1d6d60f 100644 --- a/src/services/PostService.ts +++ b/src/services/PostService.ts @@ -115,6 +115,14 @@ export class PostService { embedding = null; } const freshPost = await postRepository.createPost(post.title, post.description, categories, eventTags, post.condition, post.originalPrice, images, user, (embedding ?? []) as number[]); + + if (eventTags.length > 0) { + const eventPostRepository = Repositories.eventPost(transactionalEntityManager); + for (const tag of eventTags) { + await eventPostRepository.upsertRelationship(freshPost.id, tag.id, 'user', null); + } + } + if (embedding && Array.isArray(embedding) && embedding.length > 0) { const requestRepository = Repositories.request( transactionalEntityManager, @@ -522,11 +530,15 @@ export class PostService { const eventTagRepository = Repositories.eventTag(transactionalEntityManager); const eventTags = await eventTagRepository.findOrCreateByNames(addEventTagsRequest.eventTags); - // Add new event tags to existing ones const existingEventTagIds = new Set(post.eventTags?.map(tag => tag.id) || []); const newEventTags = eventTags.filter(tag => !existingEventTagIds.has(tag.id)); - post.eventTags = [...(post.eventTags || []), ...newEventTags]; + const eventPostRepository = Repositories.eventPost(transactionalEntityManager); + for (const tag of newEventTags) { + await eventPostRepository.upsertRelationship(post.id, tag.id, 'user', null); + } + + post.eventTags = [...(post.eventTags || []), ...newEventTags]; return await postRepository.savePost(post); }); } @@ -538,9 +550,20 @@ export class PostService { if (!post) throw new NotFoundError('Post not found!'); if (user.firebaseUid !== post.user?.firebaseUid) throw new ForbiddenError('User is not the post owner!'); - const tagsToRemove = new Set(removeEventTagsRequest.eventTags); - post.eventTags = post.eventTags?.filter(tag => !tagsToRemove.has(tag.name)) || []; + const tagsToRemoveNames = new Set(removeEventTagsRequest.eventTags); + const removedTags = post.eventTags?.filter(tag => tagsToRemoveNames.has(tag.name)) || []; + + const eventPostRepository = Repositories.eventPost(transactionalEntityManager); + for (const tag of removedTags) { + await eventPostRepository.deleteRelationship(post.id, tag.id); + + const remainingSeeds = await eventPostRepository.getUserTaggedPostsForEvent(tag.id); + if (remainingSeeds.length === 0) { + await eventPostRepository.deleteRelationshipsBySourceForEvent(tag.id, 'similarity'); + } + } + post.eventTags = post.eventTags?.filter(tag => !tagsToRemoveNames.has(tag.name)) || []; return await postRepository.savePost(post); }); } diff --git a/src/tests/EventTest.test.ts b/src/tests/EventTest.test.ts new file mode 100644 index 0000000..0d2783f --- /dev/null +++ b/src/tests/EventTest.test.ts @@ -0,0 +1,485 @@ +import { Connection } from "typeorm"; + +import { EventController } from "src/api/controllers/EventController"; +import { PostController } from "src/api/controllers/PostController"; +import { EventTagModel } from "../models/EventTagModel"; +import { EventPostModel } from "../models/EventPostModel"; +import { EventPostService } from "../services/EventPostService"; +import { ControllerFactory } from "./controllers"; +import { DatabaseConnection, DataFactory, PostFactory, UserFactory } from "./data"; + +let conn: Connection; +let eventController: EventController; +let postController: PostController; + +beforeAll(async () => { + await DatabaseConnection.connect(); +}); + +beforeEach(async () => { + await DatabaseConnection.clear(); + conn = await DatabaseConnection.connect(); + eventController = ControllerFactory.event(conn); + postController = ControllerFactory.post(conn); +}); + +afterAll(async () => { + await DatabaseConnection.close(); +}); + +describe("getAvailableEventTags", () => { + test("returns empty array when no event tags exist", async () => { + const user = UserFactory.fakeTemplate(); + const result = await eventController.getAvailableEventTags(user); + expect(result).toEqual([]); + }); + + test("returns all event tags ordered alphabetically", async () => { + const tag1 = new EventTagModel(); + tag1.name = "SPRING_FAIR"; + + const tag2 = new EventTagModel(); + tag2.name = "CLEARANCE"; + + const tag3 = new EventTagModel(); + tag3.name = "HOLIDAY_SALE"; + + await conn.manager.save([tag1, tag2, tag3]); + + const user = UserFactory.fakeTemplate(); + const result = await eventController.getAvailableEventTags(user); + + expect(result).toHaveLength(3); + expect(result[0].name).toBe("CLEARANCE"); + expect(result[1].name).toBe("HOLIDAY_SALE"); + expect(result[2].name).toBe("SPRING_FAIR"); + }); +}); + +describe("getEventPosts", () => { + test("throws NotFoundError when event tag does not exist", async () => { + const user = UserFactory.fakeTemplate(); + await new DataFactory().createUsers(user).write(); + + await expect( + eventController.getEventPosts( + user, + "00000000-0000-0000-0000-000000000000", + ), + ).rejects.toThrow("Event not found!"); + }); + + test("returns empty posts when no relationships exist", async () => { + const user = UserFactory.fakeTemplate(); + await new DataFactory().createUsers(user).write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + const result = await eventController.getEventPosts(user, tag.id); + + expect(result.posts).toHaveLength(0); + expect(result.total).toBe(0); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); + + test("returns user-tagged posts for an event", async () => { + const user = UserFactory.fakeTemplate(); + const post = PostFactory.fakeTemplate(); + post.user = user; + + await new DataFactory().createUsers(user).createPosts(post).write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + const relationship = new EventPostModel(); + relationship.postId = post.id; + relationship.eventTagId = tag.id; + relationship.source = "user"; + relationship.relevanceScore = null; + await conn.manager.save(relationship); + + const result = await eventController.getEventPosts(user, tag.id); + + expect(result.posts).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.posts[0].id).toBe(post.id); + expect(result.posts[0].source).toBe("user"); + }); + + test("filters out posts from inactive users", async () => { + const activeUser = UserFactory.fakeTemplate(); + + const inactiveUser = UserFactory.fakeTemplate2(); + inactiveUser.isActive = false; + + const activePost = PostFactory.fakeTemplate(); + activePost.user = activeUser; + + const inactivePost = PostFactory.fake(); + inactivePost.user = inactiveUser; + + await new DataFactory() + .createUsers(activeUser, inactiveUser) + .createPosts(activePost, inactivePost) + .write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + const rel1 = new EventPostModel(); + rel1.postId = activePost.id; + rel1.eventTagId = tag.id; + rel1.source = "user"; + rel1.relevanceScore = null; + + const rel2 = new EventPostModel(); + rel2.postId = inactivePost.id; + rel2.eventTagId = tag.id; + rel2.source = "user"; + rel2.relevanceScore = null; + + await conn.manager.save([rel1, rel2]); + + const result = await eventController.getEventPosts(activeUser, tag.id); + + expect(result.posts).toHaveLength(1); + expect(result.posts[0].id).toBe(activePost.id); + }); + + test("filters out posts from blocked users", async () => { + const currentUser = UserFactory.fakeTemplate(); + + const blockedUser = UserFactory.fakeTemplate2(); + currentUser.blocking = [blockedUser]; + + const normalPost = PostFactory.fakeTemplate(); + normalPost.user = currentUser; + + const blockedPost = PostFactory.fake(); + blockedPost.user = blockedUser; + + await new DataFactory() + .createUsers(currentUser, blockedUser) + .createPosts(normalPost, blockedPost) + .write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + const rel1 = new EventPostModel(); + rel1.postId = normalPost.id; + rel1.eventTagId = tag.id; + rel1.source = "user"; + rel1.relevanceScore = null; + + const rel2 = new EventPostModel(); + rel2.postId = blockedPost.id; + rel2.eventTagId = tag.id; + rel2.source = "user"; + rel2.relevanceScore = null; + + await conn.manager.save([rel1, rel2]); + + const result = await eventController.getEventPosts(currentUser, tag.id); + + expect(result.posts).toHaveLength(1); + expect(result.posts[0].id).toBe(normalPost.id); + }); + + test("paginates results correctly", async () => { + const user = UserFactory.fakeTemplate(); + await new DataFactory().createUsers(user).write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + // Create 3 posts and relationships + const posts = PostFactory.create(3); + for (const post of posts) { + post.user = user; + } + await conn.manager.save(posts); + + const relationships = posts.map((post) => { + const rel = new EventPostModel(); + rel.postId = post.id; + rel.eventTagId = tag.id; + rel.source = "user"; + rel.relevanceScore = null; + return rel; + }); + await conn.manager.save(relationships); + + const page1 = await eventController.getEventPosts(user, tag.id, 1, 2); + expect(page1.posts).toHaveLength(2); + expect(page1.total).toBe(3); + expect(page1.page).toBe(1); + expect(page1.limit).toBe(2); + + const page2 = await eventController.getEventPosts(user, tag.id, 2, 2); + expect(page2.posts).toHaveLength(1); + expect(page2.total).toBe(3); + expect(page2.page).toBe(2); + }); + + test("filters by source when provided", async () => { + const user = UserFactory.fakeTemplate(); + const post1 = PostFactory.fakeTemplate(); + post1.user = user; + const post2 = PostFactory.fake(); + post2.user = user; + + await new DataFactory().createUsers(user).createPosts(post1, post2).write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + const userRel = new EventPostModel(); + userRel.postId = post1.id; + userRel.eventTagId = tag.id; + userRel.source = "user"; + userRel.relevanceScore = null; + + const simRel = new EventPostModel(); + simRel.postId = post2.id; + simRel.eventTagId = tag.id; + simRel.source = "similarity"; + simRel.relevanceScore = 0.85; + + await conn.manager.save([userRel, simRel]); + + const userOnly = await eventController.getEventPosts(user, tag.id, 1, 10, "user"); + expect(userOnly.posts).toHaveLength(1); + expect(userOnly.posts[0].source).toBe("user"); + + const simOnly = await eventController.getEventPosts(user, tag.id, 1, 10, "similarity"); + expect(simOnly.posts).toHaveLength(1); + expect(simOnly.posts[0].source).toBe("similarity"); + expect(simOnly.posts[0].relevanceScore).toBeCloseTo(0.85); + + const all = await eventController.getEventPosts(user, tag.id); + expect(all.posts).toHaveLength(2); + }); +}); + +describe("eventPosts integration", () => { + test("createPost with event tags populates event feed", async () => { + const user = UserFactory.fakeTemplate(); + await new DataFactory().createUsers(user).write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + const createResp = await postController.createPost(user, { + title: "Spring Item", + description: "For the spring fair", + categories: [], + eventTags: ["SPRING_FAIR"], + condition: "NEW", + originalPrice: 25.0, + imagesBase64: [], + userId: user.firebaseUid, + }); + + const result = await eventController.getEventPosts(user, tag.id); + + expect(result.posts).toHaveLength(1); + expect(result.posts[0].id).toBe(createResp.post.id); + expect(result.posts[0].source).toBe("user"); + expect(result.posts[0].relevanceScore).toBeNull(); + }); + + test("addEventTagsToPost populates event feed", async () => { + const user = UserFactory.fakeTemplate(); + const post = PostFactory.fakeTemplate(); + post.user = user; + + await new DataFactory().createUsers(user).createPosts(post).write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + await postController.addEventTagsToPost( + user, + { id: post.id } as any, + { eventTags: ["SPRING_FAIR"] }, + ); + + const result = await eventController.getEventPosts(user, tag.id); + + expect(result.posts).toHaveLength(1); + expect(result.posts[0].id).toBe(post.id); + expect(result.posts[0].source).toBe("user"); + expect(result.posts[0].relevanceScore).toBeNull(); + }); + + test("removeEventTagsFromPost removes post from event feed", async () => { + const user = UserFactory.fakeTemplate(); + const post = PostFactory.fakeTemplate(); + post.user = user; + + await new DataFactory().createUsers(user).createPosts(post).write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + await postController.addEventTagsToPost( + user, + { id: post.id } as any, + { eventTags: ["SPRING_FAIR"] }, + ); + + await postController.removeEventTagsFromPost( + user, + { id: post.id } as any, + { eventTags: ["SPRING_FAIR"] }, + ); + + const result = await eventController.getEventPosts(user, tag.id); + + expect(result.posts).toHaveLength(0); + expect(result.total).toBe(0); + }); +}); + +describe("processEventSimilarity", () => { + function withEnvOverride(fn: () => Promise): Promise { + const orig = process.env.NODE_ENV; + process.env.NODE_ENV = "integration"; + return fn().finally(() => { process.env.NODE_ENV = orig; }); + } + + test("creates similarity rows for matching posts and skips dissimilar ones", async () => { + const user1 = UserFactory.fakeTemplate(); + const user2 = UserFactory.fake(); + + // Anchor: first 256 dims active + const anchorPost = PostFactory.fakeTemplate(); + anchorPost.user = user1; + anchorPost.embedding = new Array(512).fill(0); + for (let i = 0; i < 256; i++) anchorPost.embedding[i] = 0.8; + + // Similar to anchor: same direction, different magnitude → cosine ≈ 1.0 + const similarPost = PostFactory.fake(); + similarPost.user = user2; + similarPost.embedding = new Array(512).fill(0); + for (let i = 0; i < 256; i++) similarPost.embedding[i] = 0.75; + + // Orthogonal to anchor: last 256 dims active → cosine = 0 + const differentPost = PostFactory.fake(); + differentPost.user = user2; + differentPost.embedding = new Array(512).fill(0); + for (let i = 256; i < 512; i++) differentPost.embedding[i] = 0.8; + + await new DataFactory() + .createUsers(user1, user2) + .createPosts(anchorPost, similarPost, differentPost) + .write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + const userRel = new EventPostModel(); + userRel.postId = anchorPost.id; + userRel.eventTagId = tag.id; + userRel.source = "user"; + userRel.relevanceScore = null; + await conn.manager.save(userRel); + + await withEnvOverride(async () => { + const svc = new EventPostService(conn.manager); + await svc.processEventSimilarity(tag.id); + }); + + const rows = await conn.manager.find(EventPostModel, { + where: { eventTagId: tag.id }, + }); + + // Anchor should still be user-tagged + const anchorRow = rows.find(r => r.postId === anchorPost.id); + expect(anchorRow).toBeDefined(); + expect(anchorRow!.source).toBe("user"); + + // Similar post should have a similarity row with high score + const simRow = rows.find(r => r.postId === similarPost.id); + expect(simRow).toBeDefined(); + expect(simRow!.source).toBe("similarity"); + expect(simRow!.relevanceScore).toBeGreaterThan(0.7); + + // Orthogonal post should have no row (score ≈ 0, below threshold) + const diffRow = rows.find(r => r.postId === differentPost.id); + expect(diffRow).toBeUndefined(); + }); + + test("does nothing when no user-tagged posts exist", async () => { + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + await withEnvOverride(async () => { + const svc = new EventPostService(conn.manager); + await svc.processEventSimilarity(tag.id); + }); + + const count = await conn.manager.count(EventPostModel, { + where: { eventTagId: tag.id }, + }); + expect(count).toBe(0); + }); + + test("is idempotent — re-running does not duplicate similarity rows", async () => { + const user1 = UserFactory.fakeTemplate(); + const user2 = UserFactory.fake(); + + const anchorPost = PostFactory.fakeTemplate(); + anchorPost.user = user1; + anchorPost.embedding = new Array(512).fill(0); + for (let i = 0; i < 256; i++) anchorPost.embedding[i] = 0.8; + + const similarPost = PostFactory.fake(); + similarPost.user = user2; + similarPost.embedding = new Array(512).fill(0); + for (let i = 0; i < 256; i++) similarPost.embedding[i] = 0.75; + + await new DataFactory() + .createUsers(user1, user2) + .createPosts(anchorPost, similarPost) + .write(); + + const tag = new EventTagModel(); + tag.name = "SPRING_FAIR"; + await conn.manager.save(tag); + + const userRel = new EventPostModel(); + userRel.postId = anchorPost.id; + userRel.eventTagId = tag.id; + userRel.source = "user"; + userRel.relevanceScore = null; + await conn.manager.save(userRel); + + await withEnvOverride(async () => { + const svc = new EventPostService(conn.manager); + await svc.processEventSimilarity(tag.id); + await svc.processEventSimilarity(tag.id); + }); + + const simRows = await conn.manager.find(EventPostModel, { + where: { eventTagId: tag.id, source: "similarity" as any }, + }); + expect(simRows).toHaveLength(1); + expect(simRows[0].postId).toBe(similarPost.id); + }); +}); diff --git a/src/tests/controllers/ControllerFactory.ts b/src/tests/controllers/ControllerFactory.ts index 53f6b1f..bdd214d 100644 --- a/src/tests/controllers/ControllerFactory.ts +++ b/src/tests/controllers/ControllerFactory.ts @@ -1,6 +1,7 @@ import { Connection } from "typeorm"; import { AuthController } from "../../api/controllers/AuthController"; +import { EventController } from "../../api/controllers/EventController"; import { PostController } from "../../api/controllers/PostController"; import { RequestController } from "../../api/controllers/RequestController"; import { UserController } from "../../api/controllers/UserController"; @@ -16,6 +17,7 @@ import { TransactionReviewController } from "../../api/controllers/TransactionRe import { TransactionReviewService } from "../../services/TransactionReviewService"; import { NotifService } from "../../services/NotifService"; import { NotifController } from "../../api/controllers/NotifController"; +import { EventService } from "../../services/EventService"; export class ControllerFactory { public static user(conn: Connection): UserController { @@ -59,4 +61,9 @@ export class ControllerFactory { const notifService = new NotifService(conn.manager); return new NotifController(notifService); } + + public static event(conn: Connection): EventController { + const eventService = new EventService(conn.manager); + return new EventController(eventService); + } } diff --git a/src/tests/data/DatabaseConnection.ts b/src/tests/data/DatabaseConnection.ts index 2e66703..5c22bbc 100644 --- a/src/tests/data/DatabaseConnection.ts +++ b/src/tests/data/DatabaseConnection.ts @@ -36,6 +36,7 @@ export class DatabaseConnection { "Report", "postCategories", "postEventTags", + "eventPosts", "Post", "Category", "EventTag", diff --git a/src/types/ApiResponses.ts b/src/types/ApiResponses.ts index fd60551..a279c4f 100644 --- a/src/types/ApiResponses.ts +++ b/src/types/ApiResponses.ts @@ -5,6 +5,7 @@ import { PostModel } from "../models/PostModel"; import { UserModel } from "../models/UserModel"; import { ReportModel } from "../models/ReportModel"; import { MessageModel } from "../models/MessageModel"; +import { EventPostSource } from "../models/EventPostModel"; // RESPONSE TYPES @@ -74,6 +75,22 @@ export interface EventTag { posts: PostModel[]; } +// EVENT FEED + +export type { EventPostSource }; + +export type PostWithSource = PostModel & { + source: EventPostSource; + relevanceScore: number | null; +}; + +export interface GetEventPostsResponse { + posts: PostWithSource[]; + total: number; + page: number; + limit: number; +} + // POST export interface Post { diff --git a/swagger.json b/swagger.json index 1b88a22..bd320b7 100644 --- a/swagger.json +++ b/swagger.json @@ -59,6 +59,10 @@ { "name": "UserReview", "description": "User review management endpoints" + }, + { + "name": "Event", + "description": "Event tagging and feed endpoints" } ], "paths": { @@ -3115,6 +3119,85 @@ } } } + }, + "/event/available-for-tagging/": { + "get": { + "tags": ["Event"], + "summary": "Get events available for tagging", + "description": "Returns all event tags that are currently available for users to tag their posts with.", + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "List of available event tags", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTag" + } + } + } + } + } + } + } + }, + "/event/{eventTagId}/posts/": { + "get": { + "tags": ["Event"], + "summary": "Get posts for an event feed", + "description": "Returns a paginated list of posts associated with an event, combining user-tagged, similarity, and NLP-sourced posts. Posts are ordered by layer priority (user → similarity → NLP) and then by relevance score or recency within each layer.", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "eventTagId", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "The UUID of the event tag" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { "type": "integer", "default": 1 }, + "description": "Page number for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer", "default": 10 }, + "description": "Number of posts per page" + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["user", "similarity", "nlp_context"] + }, + "description": "Filter posts by source layer" + } + ], + "responses": { + "200": { + "description": "Paginated event feed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventPostsResponse" + } + } + } + }, + "404": { + "description": "Event not found" + } + } + } } }, "components": { @@ -4188,6 +4271,64 @@ } } } + }, + "EventTag": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the event tag" + }, + "name": { + "type": "string", + "description": "Name of the event tag (e.g. SPRING_FAIR, CLEARANCE)" + } + } + }, + "PostWithSource": { + "allOf": [ + { "$ref": "#/components/schemas/Post" }, + { + "type": "object", + "required": ["source", "relevanceScore"], + "properties": { + "source": { + "type": "string", + "enum": ["user", "similarity", "nlp_context"], + "description": "Which layer added this post to the event feed" + }, + "relevanceScore": { + "type": "number", + "nullable": true, + "description": "Similarity or NLP relevance score (null for user-tagged posts)" + } + } + } + ] + }, + "GetEventPostsResponse": { + "type": "object", + "required": ["posts", "total", "page", "limit"], + "properties": { + "posts": { + "type": "array", + "items": { "$ref": "#/components/schemas/PostWithSource" } + }, + "total": { + "type": "integer", + "description": "Total number of posts matching the query" + }, + "page": { + "type": "integer", + "description": "Current page number" + }, + "limit": { + "type": "integer", + "description": "Number of posts per page" + } + } } } }