diff --git a/services/wiki/src/__tests__/resources/list.test.ts b/services/wiki/src/__tests__/resources/list.test.ts index cc07be2b..bd3fb577 100644 --- a/services/wiki/src/__tests__/resources/list.test.ts +++ b/services/wiki/src/__tests__/resources/list.test.ts @@ -2,21 +2,35 @@ import supertest from 'supertest' import { expect, it, describe, beforeAll, afterAll } from 'vitest' import { Prisma, - Category, - RESOURCE_TYPE, - Resource, - User, - ViewedResource, + // Category, + // RESOURCE_TYPE, + // Resource, + // User, + // ViewedResource, } from '@prisma/client' import qs from 'qs' import { z } from 'zod' import { server, testCategoryData, testUserData } from '../globalSetup' import { pathRoot } from '../../routes/routes' import { prisma } from '../../prisma/client' -import { resourceGetSchema, topicSchema } from '../../schemas' -import { resourceTestData } from '../mocks/resources' +import { resourceGetSchema } from '../../schemas' +import { + knexResourceTestDataUpdated, + resourceTestData, +} from '../mocks/resources' import { checkInvalidToken } from '../helpers/checkInvalidToken' import { authToken } from '../mocks/ssoHandlers/authToken' +import { knexResourceGetSchema } from '../../schemas/resource/resourceGetSchema' +import { knexTopicSchema } from '../../schemas/topic/topicSchema' +import { + KnexResource, + Resource, + ViewedResource, + User, + Category, +} from '../../db/knexTypes' +import db from '../../db/knex' +// import { User } from '../../db/knexTypes' type ResourceVotes = { [key: string]: number @@ -34,20 +48,19 @@ let adminUser: User | null let userWithNoName: User | null beforeAll(async () => { - const testCategory = (await prisma.category.findUnique({ - where: { slug: testCategoryData.slug }, - })) as Category - user = await prisma.user.findFirst({ - where: { id: testUserData.user.id }, - }) - adminUser = await prisma.user.findFirst({ - where: { id: testUserData.admin.id }, - }) - userWithNoName = await prisma.user.findFirst({ - where: { id: testUserData.userWithNoName.id }, - }) + const testCategory = (await db('category') + .where({ slug: testCategoryData.slug }) + .first()) as Category + + user = await db('user').where({ id: testUserData.user.id }).first() + + adminUser = await db('user').where({ id: testUserData.admin.id }).first() - const testResources = resourceTestData.map((testResource) => ({ + userWithNoName = await db('user') + .where({ id: testUserData.userWithNoName.id }) + .first() + + const testResources = knexResourceTestDataUpdated.map((testResource) => ({ ...testResource, user: { connect: { id: user?.id } }, topics: { @@ -122,9 +135,9 @@ afterAll(async () => { }) }) // resourceTypes as array from prisma types for the it.each tests. -const resourceTypes = Object.keys(RESOURCE_TYPE) -type ResourceGetSchema = z.infer -type TopicSchema = z.infer +const resourceTypes = Object.keys(KnexResource) +type ResourceGetSchema = z.infer +type TopicSchema = z.infer describe('Testing resources GET endpoint', () => { it('should fail with wrong resourceType', async () => { @@ -135,7 +148,7 @@ describe('Testing resources GET endpoint', () => { expect(response.status).toBe(400) }) - it('should get all resources by topic id', async () => { + it.only('should get all resources by topic id', async () => { const existingTopic = await prisma.topic.findUnique({ where: { slug: 'testing' }, }) @@ -149,9 +162,7 @@ describe('Testing resources GET endpoint', () => { expect(response.body.length).toBeGreaterThanOrEqual(1) response.body.forEach((resource: ResourceGetSchema) => { expect(() => resourceGetSchema.parse(resource)).not.toThrow() - expect( - resource.topics.map((t: { topic: TopicSchema }) => t.topic.id) - ).toContain(topicId) + expect(resource.topics.map((t: TopicSchema) => t.id)).toContain(topicId) }) }) @@ -176,9 +187,9 @@ describe('Testing resources GET endpoint', () => { expect(() => resourceGetSchema.parse(resource)).not.toThrow() // The returned resource has at least a topic related to the queried category expect( - resource.topics.some(async (t: { topic: TopicSchema }) => { + resource.topics.some(async (t: TopicSchema) => { const categoryFromTopic = await prisma.topic.findUnique({ - where: { id: t.topic.id }, + where: { id: t.id }, include: { category: { select: { slug: true } } }, }) return categoryFromTopic?.category.slug === categorySlug @@ -209,15 +220,13 @@ describe('Testing resources GET endpoint', () => { expect(response.body.length).toBeGreaterThanOrEqual(1) response.body.forEach((resource: ResourceGetSchema) => { expect(() => resourceGetSchema.parse(resource)).not.toThrow() - expect( - resource.topics.map((t: { topic: TopicSchema }) => t.topic.id) - ).toContain(topicId) - expect(resource.resourceType).toBe(resourceType) + expect(resource.topics.map((t: TopicSchema) => t.id)).toContain(topicId) + expect(resource.resource_type).toBe(resourceType) // The returned resource has at least a topic related to the queried category expect( - resource.topics.some(async (t: { topic: TopicSchema }) => { + resource.topics.some(async (t: TopicSchema) => { const categoryFromTopic = await prisma.topic.findUnique({ - where: { id: t.topic.id }, + where: { id: t.id }, include: { category: { select: { slug: true } } }, }) return categoryFromTopic?.category.slug === categorySlug @@ -239,9 +248,7 @@ describe('Testing resources GET endpoint', () => { response.body.forEach((resource: ResourceGetSchema) => { expect(() => resourceGetSchema.parse(resource)).not.toThrow() expect( - resource.topics.some( - (topic: { topic: TopicSchema }) => topic.topic.slug === topicSlug - ) + resource.topics.some((topic: TopicSchema) => topic.slug === topicSlug) ).toBe(true) }) }) diff --git a/services/wiki/src/controllers/resources/getResourcesById.ts b/services/wiki/src/controllers/resources/getResourcesById.ts index 62c882d1..6f58b712 100644 --- a/services/wiki/src/controllers/resources/getResourcesById.ts +++ b/services/wiki/src/controllers/resources/getResourcesById.ts @@ -1,5 +1,4 @@ import Koa, { Middleware } from 'koa' -// import { User } from '@prisma/client' import { NotFoundError } from '../../helpers/errors' import db from '../../db/knex' import { attachUserNamesToResources } from '../../helpers/wiki/attachUserNamesToResources' diff --git a/services/wiki/src/controllers/resources/list.ts b/services/wiki/src/controllers/resources/list.ts index c010f334..c7149b5b 100644 --- a/services/wiki/src/controllers/resources/list.ts +++ b/services/wiki/src/controllers/resources/list.ts @@ -1,17 +1,18 @@ -import { Prisma, User } from '@prisma/client' import Koa, { Middleware } from 'koa' -import { prisma } from '../../prisma/client' -import { resourceGetSchema } from '../../schemas' import { TResourcesListParamsSchema } from '../../schemas/resource/resourcesListParamsSchema' +import db from '../../db/knex' +import { User } from '../../db/knexTypes' +import { attachUserNamesToResourcesKnex } from '../../helpers/attachUserNamesToResources' import { - attachUserNamesToResources, - markFavorites, - transformResourceToAPI, - ExtendedFavoriteResourceWithName, -} from '../../helpers' + ExtendedFavoriteResourceWithNameKnex, + markFavoritesKnex, +} from '../../helpers/markFavorites' +import { transformResourceToAPIKnex } from '../../helpers/transformResourceToAPI' +import { knexResourceGetSchema } from '../../schemas/resource/resourceGetSchema' export const listResources: Middleware = async (ctx: Koa.Context) => { const user = ctx.user as User | null + const { resourceTypes, topic: topicId, @@ -20,48 +21,80 @@ export const listResources: Middleware = async (ctx: Koa.Context) => { status, search, } = ctx.query as TResourcesListParamsSchema - let statusCondition: Prisma.Enumerable = {} - if (user && status) { - const viewedFilter = { userId: user.id } - if (status === 'SEEN') { - statusCondition = { AND: { viewed: { some: viewedFilter } } } - } else if (status === 'NOT_SEEN') { - statusCondition = { NOT: { viewed: { some: viewedFilter } } } - } - } - const where: Prisma.ResourceWhereInput = { - topics: { - some: { - topic: { - category: { slug: categorySlug }, - id: topicId, - slug: topicSlug, - }, - }, - }, - resourceType: { in: resourceTypes }, - ...statusCondition, - ...(search && - search.trim().length >= 2 && { - OR: [ - { title: { contains: search, mode: 'insensitive' } }, - { description: { contains: search, mode: 'insensitive' } }, - ], - }), - } - const voteSelect = - ctx.user !== null ? { userId: true, vote: true } : { vote: true } - const resources = await prisma.resource.findMany({ - where, - include: { - vote: { select: voteSelect }, - topics: { select: { topic: true } }, - favorites: { - where: { userId: user ? user.id : undefined }, - }, - }, - }) + const resources = await db('resource') + .leftJoin('topic_resource', 'resource.id', 'topic_resource.resource_id') + .leftJoin('topic', 'topic.id', 'topic_resource.topic_id') + .leftJoin('category', 'category.id', 'topic.category_id') + .leftJoin('vote', 'resource.id', 'vote.resource_id') + .leftJoin('favorites', 'resource.id', 'favorites.resource_id') + .leftJoin('viewed_resource', 'resource.id', 'viewed_resource.resource_id') + .whereExists(function () { + this.select('*') + .from('topic_resource') + .leftJoin('topic', 'topic.id', 'topic_resource.topic_id') // Necesario para acceder a 'topic.id' y 'topic.slug' + .leftJoin('category', 'category.id', 'topic.category_id') // Necesario para acceder a 'topic.id' y 'topic.slug' + .whereRaw('topic_resource.resource_id = resource.id') + .andWhere(function () { + if (topicId) { + this.where('topic.id', topicId) + } + if (topicSlug) { + this.orWhere('topic.slug', topicSlug) + } + if (categorySlug) { + this.where('category.slug', categorySlug) + } + }) + }) + .modify((query) => { + if (user && status) { + if (status === 'SEEN') { + query.where('viewed_resource.user_id', user.id) + } + if (status === 'NOT_SEEN') { + query.whereNot('viewed_resource.user_id', user.id) + } + } + }) + .modify((query) => { + if (resourceTypes && resourceTypes.length > 0) { + query.where('resource.resource_type', resourceTypes) + } + + if (search && search.trim().length >= 2) { + query.where(function () { + this.where('resource.title', 'ilike', `%${search}%`).orWhere( + 'resource.description', + 'ilike', + `%${search}%` + ) + }) + } + }) + .modify((query) => { + if (user !== null) { + query.select(db.raw('json_agg(vote.user_id, vote.vote) AS vote')) + } else { + query.select(db.raw('json_agg(vote.vote) AS vote')) + } + }) + .select( + 'resource.*', + // db.raw('json_agg(vote.*) AS vote') + db.raw('json_agg(topic.*) AS topics') + // db.raw('json_agg(favorites.*) AS favorites') + ) + .modify((query) => { + if (user && user.id) { + query + .where('favorites.user_id', user.id) + .select(db.raw('json_agg(favorites.*) AS favorites')) + } else { + query.select(db.raw('json_build_array() AS favorites')) + } + }) + .groupBy('resource.id') if (resources.length === 0) { ctx.status = 200 @@ -69,18 +102,19 @@ export const listResources: Middleware = async (ctx: Koa.Context) => { return } - const resourcesWithUserName = await attachUserNamesToResources(resources) - const resourcesWithFavorites = markFavorites( + const resourcesWithUserName = await attachUserNamesToResourcesKnex(resources) + + const resourcesWithFavorites = markFavoritesKnex( resourcesWithUserName.filter( - (resource): resource is ExtendedFavoriteResourceWithName => + (resource): resource is ExtendedFavoriteResourceWithNameKnex => 'favorites' in resource ), user ) const parsedResources = resourcesWithFavorites.map((resource) => - resourceGetSchema.parse( - transformResourceToAPI(resource, user ? user.id : undefined) + knexResourceGetSchema.parse( + transformResourceToAPIKnex(resource, user ? user.id : undefined) ) ) diff --git a/services/wiki/src/db/knexTypes.d.ts b/services/wiki/src/db/knexTypes.d.ts new file mode 100644 index 00000000..fc770fd0 --- /dev/null +++ b/services/wiki/src/db/knexTypes.d.ts @@ -0,0 +1,132 @@ +/** + * Model User + * + */ +export type User = { + id: string + created_at: Date + updated_at: Date + createdAt?: Date + updatedAt?: Date +} + +/** + * Model Resource + * + */ +export type Resource = { + id: string + title: string + slug: string + description: string | null + url: string + resource_type?: TRESOURCE + resourceType?: TRESOURCE + user_id?: string + userId?: string + category_id?: string + categoryId?: string + created_at?: Date + updated_at?: Date + createdAt?: Date + updatedAt?: Date +} + +/** + * Model Category + * + */ +export type Category = { + id: string + name: string + slug: string + created_at: Date + updated_at: Date + media_id: string | null +} + +/** + * Model Topic + * + */ +export type Topic = { + id: string + name: string + slug: string + categoryId?: string + category_id?: string + createdAt?: Date + createdAt?: Date + updated_at?: Date + updated_at?: Date +} + +/** + * Model Media + * + */ +export type Media = { + id: string + mimeType: string + filePath: string + user_id: string + created_at: Date + updated_at: Date +} + +/** + * Model TopicsOnResources + * + */ +export type TopicsOnResources = { + resource_id: string + topic_id: string + created_at: Date +} + +/** + * Model Favorites + * + */ +export type Favorites = { + user_id?: string + userId?: string + resource_id: string + created_at: Date +} + +/** + * Model ViewedResource + * + */ +export type ViewedResource = { + user_id: string + resource_id: string +} + +/** + * Model Vote + * + */ +export type Vote = { + user_id?: string + userId?: string + resource_id: string + vote: number + created_at: Date + updated_at: Date +} + +/** + * Enums + */ + +// Based on +// https://github.com/microsoft/TypeScript/issues/3192#issuecomment-261720275 +export type TRESOURCE = { + BLOG: 'BLOG' + VIDEO: 'VIDEO' + TUTORIAL: 'TUTORIAL' +} + +// export type RESOURCE = (typeof RESOURCE_TYPE)[keyof typeof RESOURCE_TYPE] diff --git a/services/wiki/src/db/knexTypes.ts b/services/wiki/src/db/knexTypes.ts index 329fb09f..e90b96f4 100644 --- a/services/wiki/src/db/knexTypes.ts +++ b/services/wiki/src/db/knexTypes.ts @@ -6,6 +6,8 @@ export type User = { id: string created_at: Date updated_at: Date + // createdAt?: Date + // updatedAt?: Date } /** @@ -80,7 +82,7 @@ export type TopicsOnResources = { * */ export type Favorites = { - user_id: string + user_id?: string resource_id: string created_at: Date } diff --git a/services/wiki/src/helpers/attachUserNamesToResources.ts b/services/wiki/src/helpers/attachUserNamesToResources.ts index 14731f5e..ff930868 100644 --- a/services/wiki/src/helpers/attachUserNamesToResources.ts +++ b/services/wiki/src/helpers/attachUserNamesToResources.ts @@ -1,4 +1,9 @@ -import { Favorites, Resource, Topic } from '@prisma/client' +import { Resource, Topic, Favorites } from '@prisma/client' +import { + Favorites as favorito, + Topic as topico, + Resource as resorcito, +} from '../db/knexTypes' import { ssoHandler } from './ssoHandler' type ResourceWithTopicsVote = Resource & { @@ -10,12 +15,30 @@ type ResourceWithTopicsVote = Resource & { userId?: string }[] } + +type ResourceWithTopicsVoteKnex = resorcito & { + topics: { + topic: topico + }[] + vote: { + vote: number + user_id?: string + }[] +} + export type ExtendedResourceWithFavorites = ResourceWithTopicsVote & { favorites: Favorites[] } +export type ExtendedResourceWithFavoritesKnex = ResourceWithTopicsVoteKnex & { + favorites: favorito[] +} type UnifiedResources = ResourceWithTopicsVote | ExtendedResourceWithFavorites +type UnifiedResourcesKnex = + | ResourceWithTopicsVoteKnex + | ExtendedResourceWithFavoritesKnex + export type ResourceWithUserName = | (Omit & { user: { name: string; id: string } @@ -24,6 +47,14 @@ export type ResourceWithUserName = user: { name: string; id: string } }) +export type ResourceWithUserNameKnex = + | (Omit & { + user: { name: string; id: string } + }) + | (Omit & { + user: { name: string; id: string } + }) + export async function attachUserNamesToResources( resources: UnifiedResources[] ) { @@ -49,3 +80,29 @@ export async function attachUserNamesToResources( return acc }, []) } + +export async function attachUserNamesToResourcesKnex( + resources: UnifiedResourcesKnex[] +) { + const userIds = resources.map((resource) => resource.user_id) + const names = await ssoHandler.listUsers(userIds) + return resources.reduce((acc, resource) => { + const user = names.find((u) => u.id === resource.user_id) + if (!user) return acc + + const { user_id: userId, ...resourceWithoutUserId } = resource + + const updatedResource = { + ...resourceWithoutUserId, + user: { + ...('user' in resource && resource.user ? resource.user : {}), + id: userId, + name: user.name, + }, + favorites: 'favorites' in resource ? resource.favorites : [], + } + + acc.push(updatedResource as ResourceWithUserNameKnex) + return acc + }, []) +} diff --git a/services/wiki/src/helpers/markFavorites.ts b/services/wiki/src/helpers/markFavorites.ts index 589bb87f..4e3564ae 100644 --- a/services/wiki/src/helpers/markFavorites.ts +++ b/services/wiki/src/helpers/markFavorites.ts @@ -1,5 +1,10 @@ import { User } from '@prisma/client' -import { ExtendedResourceWithFavorites } from './attachUserNamesToResources' + +import { User as usercito } from '../db/knexTypes' +import { + ExtendedResourceWithFavorites, + ExtendedResourceWithFavoritesKnex, +} from './attachUserNamesToResources' export type ExtendedFavoriteResourceWithName = Omit< ExtendedResourceWithFavorites, @@ -7,6 +12,14 @@ export type ExtendedFavoriteResourceWithName = Omit< > & { user: { name: string; id: string; avatar: string | null } } + +export type ExtendedFavoriteResourceWithNameKnex = Omit< + ExtendedResourceWithFavoritesKnex, + 'user_id' +> & { + user: { name: string; id: string; avatar: string | null } +} + export function markFavorites( resources: ExtendedFavoriteResourceWithName[], user: User | null @@ -20,3 +33,17 @@ export function markFavorites( return { ...resource, isFavorite } }) } + +export function markFavoritesKnex( + resources: ExtendedFavoriteResourceWithNameKnex[], + user: usercito | null +) { + return resources.map((resource) => { + let isFavorite = false + if (user) + isFavorite = !!resource.favorites.find( + (favorite) => favorite.user_id === user.id + ) + return { ...resource, isFavorite } + }) +} diff --git a/services/wiki/src/helpers/transformResourceToAPI.ts b/services/wiki/src/helpers/transformResourceToAPI.ts index 179c46f2..75aeb70a 100644 --- a/services/wiki/src/helpers/transformResourceToAPI.ts +++ b/services/wiki/src/helpers/transformResourceToAPI.ts @@ -1,4 +1,10 @@ -import { TResourceSchema } from '../schemas/resource/resourceSchema' +// import { KnexResource } from '../db/knexTypes' +import { Vote } from '@prisma/client' +import { Vote as votito } from '../db/knexTypes' +import { + TKnexResourceSchema, + TResourceSchema, +} from '../schemas/resource/resourceSchema' type TResource = TResourceSchema & { userId?: string @@ -8,24 +14,37 @@ type TResource = TResourceSchema & { vote: { userId?: string; vote: number }[] isFavorite?: boolean } + +type TResourceKnex = TKnexResourceSchema & { + user_id?: string + user?: { + name: string + } + vote: { user_id?: string; vote: number }[] + isFavorite?: boolean +} export type TVoteCount = { upvote: number downvote: number total: number userVote: number } -export type Vote = { - user_id: string - userId: string // TODO, old prisma prperty delete when fully migrated to Knex - resource_id: string - vote: number - created_at: Date - updated_at: Date -} +// export type Vote = { +// user_id: string +// userId: string // TODO, old prisma prperty delete when fully migrated to Knex +// resource_id: string +// vote: number +// created_at: Date +// updated_at: Date +// } type TResourceWithVoteCount = TResource & { voteCount: TVoteCount } + +type TResourceWithVoteCountKnex = TResourceKnex & { + voteCount: TVoteCount +} /** * Calculates the vote count based on an array of votes. * @@ -45,8 +64,7 @@ export function calculateVoteCount(vote: Partial[], userId?: string) { vote.forEach((_vote: Partial) => { if (_vote.vote === 1) upvote += 1 else if (_vote.vote === -1) downvote += 1 - if ((_vote.user_id === userId || _vote.userId === userId) && userId) - userVote = _vote.vote ?? 0 + if (_vote.userId === userId && userId) userVote = _vote.vote ?? 0 }) const voteCount = { upvote, @@ -56,19 +74,27 @@ export function calculateVoteCount(vote: Partial[], userId?: string) { } return voteCount } -/** - * Transforms a given resource to match the API's response schema. - * - * This function takes in a resource with associated votes and enriches it - * with a `voteCount` property. The `voteCount` is derived by computing the - * number of upvotes, downvotes, the total difference, and the vote value of - * a specific user (if a userId is provided). - * - * @param resource The resource object with associated votes. - * @param userId An optional userId to fetch the specific vote value of the user. - * - * @returns An enriched resource object containing the computed `voteCount`. - */ +export function calculateVoteCountKnex( + vote: Partial[], + userId?: string +) { + let upvote = 0 + let downvote = 0 + let userVote = 0 + vote.forEach((_vote: Partial) => { + if (_vote.vote === 1) upvote += 1 + else if (_vote.vote === -1) downvote += 1 + if (_vote.user_id === userId && userId) userVote = _vote.vote ?? 0 + }) + const voteCount = { + upvote, + downvote, + total: upvote - downvote, + userVote, + } + return voteCount +} + export function transformResourceToAPI( resource: TResource, userId?: string @@ -80,3 +106,15 @@ export function transformResourceToAPI( voteCount, } } + +export function transformResourceToAPIKnex( + resource: TResourceKnex, + userId?: string +): TResourceWithVoteCountKnex { + const voteCount = calculateVoteCountKnex(resource.vote, userId) + + return { + ...resource, + voteCount, + } +} diff --git a/services/wiki/src/schemas/resource/resourceGetSchema.ts b/services/wiki/src/schemas/resource/resourceGetSchema.ts index 3c1f0244..db294bae 100644 --- a/services/wiki/src/schemas/resource/resourceGetSchema.ts +++ b/services/wiki/src/schemas/resource/resourceGetSchema.ts @@ -1,7 +1,7 @@ import { z } from '../../openapi/zod' import { knexTopicSchema, topicSchema } from '../topic/topicSchema' import { knexUserSchema, userSchema } from '../users/userSchema' -import { knexVoteCountSchema, voteCountSchema } from '../voteCountSchema' +import { voteCountSchema } from '../voteCountSchema' import { knexResourceSchema, resourceSchema } from './resourceSchema' export const resourceGetSchema = resourceSchema.extend({ @@ -19,7 +19,7 @@ export const knexResourceGetSchema = knexResourceSchema.extend({ name: knexUserSchema.shape.name, id: knexUserSchema.shape.id, }), - topics: z.array(z.object({ topic: knexTopicSchema })), - vote_count: knexVoteCountSchema, - is_favorite: z.boolean().default(false), + topics: z.array(knexTopicSchema), + voteCount: voteCountSchema, + isFavorite: z.boolean().default(false), })