Skip to content

Commit

Permalink
feat: allow ranking posts inside collection
Browse files Browse the repository at this point in the history
  • Loading branch information
drodil committed Oct 31, 2024
1 parent 117369b commit e81f728
Show file tree
Hide file tree
Showing 14 changed files with 387 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('collection_posts', table => {
table.increments('rank', { primaryKey: false });
});
};

/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('collection_posts', table => {
table.dropColumn('rank');
});
};
95 changes: 94 additions & 1 deletion plugins/qeta-backend/src/database/DatabaseQetaStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AnswersOptions,
AttachmentParameters,
CollectionOptions,
CollectionPostRank,
Collections,
EntitiesResponse,
EntityResponse,
Expand Down Expand Up @@ -290,8 +291,21 @@ export class DatabaseQetaStore implements QetaStore {
}

if (options.collectionId) {
query.leftJoin('collection_posts', 'posts.id', 'collection_posts.postId');
query.innerJoin(
'collection_posts',
'posts.id',
'collection_posts.postId',
);
query.where('collection_posts.collectionId', options.collectionId);
} else if (options.orderBy === 'rank') {
query.innerJoin(
'collection_posts',
'posts.id',
'collection_posts.postId',
);
}
if (options.orderBy === 'rank') {
query.groupBy('rank');
}

if (options.noAnswers) {
Expand Down Expand Up @@ -1930,6 +1944,85 @@ export class DatabaseQetaStore implements QetaStore {
.delete());
}

async getPostRank(
collectionId: number,
postId: number,
): Promise<number | null> {
const rank = await this.db('collection_posts')
.where('collectionId', collectionId)
.where('postId', postId)
.select('rank')
.first();
return rank?.rank ?? null;
}

async getTopRankedPostId(
collectionId: number,
): Promise<CollectionPostRank | null> {
const post = await this.db('collection_posts')
.where('collectionId', collectionId)
.orderBy('rank', 'desc')
.limit(1)
.select(['postId', 'rank']);
return post[0] ? { postId: post[0].postId, rank: post[0].rank } : null;
}

async getBottomRankedPostId(
collectionId: number,
): Promise<CollectionPostRank | null> {
const post = await this.db('collection_posts')
.where('collectionId', collectionId)
.orderBy('rank', 'asc')
.limit(1)
.select(['postId', 'rank']);
return post[0] ? { postId: post[0].postId, rank: post[0].rank } : null;
}

async getNextRankedPostId(
collectionId: number,
postId: number,
): Promise<CollectionPostRank | null> {
const rank = await this.getPostRank(collectionId, postId);
if (rank === null) {
return null;
}
const post = await this.db('collection_posts')
.where('collectionId', collectionId)
.where('rank', '>', rank)
.orderBy('rank', 'asc')
.select(['postId', 'rank'])
.first();
return post ? { postId: post.postId, rank: post.rank } : null;
}

async getPreviousRankedPostId(
collectionId: number,
postId: number,
): Promise<CollectionPostRank | null> {
const rank = await this.getPostRank(collectionId, postId);
if (rank === null) {
return null;
}
const post = await this.db('collection_posts')
.where('collectionId', collectionId)
.where('rank', '<', rank)
.orderBy('rank', 'desc')
.select(['postId', 'rank'])
.first();
return post ? { postId: post.postId, rank: post.rank } : null;
}

async updatePostRank(
collectionId: number,
postId: number,
rank: number,
): Promise<void> {
await this.db('collection_posts')
.where('collectionId', collectionId)
.where('postId', postId)
.update({ rank });
}

private getEntitiesBaseQuery() {
const entityId = this.db.ref('entities.id');
const entityRef = this.db.ref('entities.entity_ref');
Expand Down
25 changes: 25 additions & 0 deletions plugins/qeta-backend/src/database/QetaStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface PostOptions {
author?: string | string[];
orderBy?:
| 'views'
| 'rank'
| 'title'
| 'score'
| 'answersCount'
Expand Down Expand Up @@ -172,6 +173,11 @@ export interface AttachmentParameters {
collectionId?: number;
}

export interface CollectionPostRank {
postId: number;
rank: number;
}

/**
* Interface for fetching and modifying Q&A data
*/
Expand Down Expand Up @@ -586,4 +592,23 @@ export interface QetaStore {
getAIAnswer(postId: number): Promise<AIResponse | null>;
saveAIAnswer(postId: number, response: AIResponse): Promise<void>;
deleteAIAnswer(postId: number): Promise<boolean>;

getPostRank(collectionId: number, postId: number): Promise<number | null>;
getTopRankedPostId(collectionId: number): Promise<CollectionPostRank | null>;
getBottomRankedPostId(
collectionId: number,
): Promise<CollectionPostRank | null>;
getNextRankedPostId(
collectionId: number,
postId: number,
): Promise<CollectionPostRank | null>;
getPreviousRankedPostId(
collectionId: number,
postId: number,
): Promise<CollectionPostRank | null>;
updatePostRank(
collectionId: number,
postId: number,
rank: number,
): Promise<void>;
}
115 changes: 115 additions & 0 deletions plugins/qeta-backend/src/service/routes/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import addFormats from 'ajv-formats';
import {
CollectionPostSchema,
CollectionRankPostSchema,
CollectionSchema,
CollectionsQuerySchema,
RouteOptions,
Expand Down Expand Up @@ -464,4 +465,118 @@ export const collectionsRoutes = (router: Router, options: RouteOptions) => {
response.status(200);
response.json(collection);
});

router.post('/collections/:id/rank/', async (request, response) => {
const validateRequestBody = ajv.compile(CollectionRankPostSchema);
if (!validateRequestBody(request.body)) {
response
.status(400)
.json({ errors: validateRequestBody.errors, type: 'body' });
return;
}

const collectionId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(collectionId)) {
response
.status(400)
.send({ errors: 'Invalid collection id', type: 'body' });
return;
}

const username = await getUsername(request, options);
const collection = await database.getCollection(
username,
Number.parseInt(request.params.id, 10),
);

if (!collection?.canEdit) {
response.sendStatus(401);
return;
}

const post = await database.getPost(username, request.body.postId, false);

if (!post) {
response.status(404).send({ errors: 'Post not found', type: 'body' });
return;
}

await authorize(request, qetaReadPostPermission, options, post);

const currentRank = await database.getPostRank(
collection.id,
request.body.postId,
);
if (!currentRank) {
response.status(404).send();
return;
}

if (request.body.rank === 'up') {
const higherRankPostId = await database.getNextRankedPostId(
collection.id,
request.body.postId,
);
if (higherRankPostId) {
await database.updatePostRank(
collection.id,
higherRankPostId.postId,
currentRank,
);
await database.updatePostRank(
collection.id,
request.body.postId,
higherRankPostId.rank,
);
}
} else if (request.body.rank === 'down') {
const lowerRankPostId = await database.getPreviousRankedPostId(
collection.id,
request.body.postId,
);
if (lowerRankPostId) {
await database.updatePostRank(
collection.id,
lowerRankPostId.postId,
currentRank,
);
await database.updatePostRank(
collection.id,
request.body.postId,
lowerRankPostId.rank,
);
}
} else if (request.body.rank === 'top') {
const topRankPostId = await database.getTopRankedPostId(collectionId);
if (topRankPostId) {
await database.updatePostRank(
collection.id,
topRankPostId.postId,
currentRank,
);
await database.updatePostRank(
collection.id,
request.body.postId,
topRankPostId.rank,
);
}
} else if (request.body.rank === 'bottom') {
const bottomRankPostId = await database.getBottomRankedPostId(
collection.id,
);
if (bottomRankPostId) {
await database.updatePostRank(
collection.id,
bottomRankPostId.postId,
currentRank,
);
await database.updatePostRank(
collection.id,
request.body.postId,
bottomRankPostId.rank,
);
}
}
response.sendStatus(200);
});
};
16 changes: 16 additions & 0 deletions plugins/qeta-backend/src/service/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export interface CollectionPostContent {
postId: number;
}

export interface CollectionRankContent {
postId: number;
rank: 'top' | 'bottom' | 'up' | 'down';
}

export const CollectionsQuerySchema: JSONSchemaType<CollectionsQuery> = {
type: 'object',
properties: {
Expand Down Expand Up @@ -111,6 +116,7 @@ export const PostsQuerySchema: JSONSchemaType<PostsQuery> = {
orderBy: {
type: 'string',
enum: [
'rank',
'views',
'title',
'trend',
Expand Down Expand Up @@ -278,6 +284,16 @@ export const CollectionPostSchema: JSONSchemaType<CollectionPostContent> = {
additionalProperties: false,
};

export const CollectionRankPostSchema: JSONSchemaType<CollectionRankContent> = {
type: 'object',
properties: {
postId: { type: 'integer' },
rank: { type: 'string', enum: ['top', 'bottom', 'up', 'down'] },
},
required: ['postId', 'rank'],
additionalProperties: false,
};

export interface AnswerQuestion {
answer: string;
images?: number[];
Expand Down
7 changes: 7 additions & 0 deletions plugins/qeta-common/src/api/QetaApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface PostsQuery extends PaginatedQuery {
entity?: string;
author?: string;
orderBy?:
| 'rank'
| 'views'
| 'title'
| 'score'
Expand Down Expand Up @@ -410,6 +411,12 @@ export interface QetaApi {
postId: number,
requestOptions?: RequestOptions,
): Promise<CollectionResponse>;
rankPostInCollection(
collectionId: number,
postId: number,
rank: 'top' | 'bottom' | 'up' | 'down',
requestOptions?: RequestOptions,
): Promise<boolean>;

getTemplates(requestOptions?: RequestOptions): Promise<TemplatesResponse>;
getTemplate(
Expand Down
17 changes: 17 additions & 0 deletions plugins/qeta-common/src/api/QetaClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,23 @@ export class QetaClient implements QetaApi {
return data;
}

async rankPostInCollection(
collectionId: number,
postId: number,
rank: 'top' | 'bottom' | 'up' | 'down',
requestOptions?: RequestOptions,
): Promise<boolean> {
const response = await this.fetch(`/collections/${collectionId}/rank`, {
reqInit: {
method: 'POST',
body: JSON.stringify({ postId, rank }),
headers: { 'Content-Type': 'application/json' },
},
requestOptions,
});
return response.ok;
}

async getTemplates(
requestOptions?: RequestOptions,
): Promise<TemplatesResponse> {
Expand Down
Loading

0 comments on commit e81f728

Please sign in to comment.