diff --git a/packages/content-common/src/types.ts b/packages/content-common/src/types.ts index 77bf753e..684e9d3c 100644 --- a/packages/content-common/src/types.ts +++ b/packages/content-common/src/types.ts @@ -75,6 +75,12 @@ export type ApprovedItemRequiredInput = { isTimeSensitive: boolean; }; +export type CreateSectionItemApiInput = { + sectionExternalId: string; + approvedItemExternalId: string; + rank?: number; +}; + // maps to the CreateApprovedCorpusItemInput type in corpus API admin schema export type CreateApprovedCorpusItemApiInput = ApprovedItemRequiredInput & { // These required properties are set once only at creation time @@ -217,6 +223,7 @@ export type ScheduledSurface = { prospectTypes: ProspectType[]; accessGroup: string; }; + export const ScheduledSurfaces: ScheduledSurface[] = [ { name: 'New Tab (en-US)', diff --git a/servers/curated-corpus-api/schema-admin.graphql b/servers/curated-corpus-api/schema-admin.graphql index 137d3bbf..fece0849 100644 --- a/servers/curated-corpus-api/schema-admin.graphql +++ b/servers/curated-corpus-api/schema-admin.graphql @@ -533,6 +533,54 @@ type ScheduleReview { reviewedAt: Date! } +""" +An ApprovedItem belonging to a Section +""" +type SectionItem { + """ + An alternative primary key in UUID format that is generated on creation. + """ + externalId: ID! + """ + The associated Approved Item. + """ + approvedItem: ApprovedCorpusItem! + """ + The initial rank of the SectionItem in relation to its siblings. Used as a + fallback in Merino when there is no engagement/click data available. May only apply to + ML-generated SectionItems. + """ + rank: Int + """ + A Unix timestamp of when the entity was created. + """ + createdAt: Int! + """ + A Unix timestamp of when the entity was last updated. + """ + updatedAt: Int! +} + +""" +Input data for adding a SectionItem to a Section +""" +input CreateSectionItemInput { + """ + The ID of the ApprovedItem backing the SectionItem. + """ + approvedItemExternalId: ID! + """ + The ID of the Section to contain the new SectionItem. + """ + sectionExternalId: ID! + """ + The initial rank of the SectionItem in relation to its siblings. Used as a + fallback in Merino when there is no engagement/click data available. May only apply to + ML-generated SectionItems. + """ + rank: Int +} + """ Input data for marking the given scheduled surface as reviewed by human curators for a given date. @@ -1199,6 +1247,11 @@ type Mutation { Marks the given scheduled surface as reviewed by human curators for a given date. """ createScheduleReview(data: CreateScheduleReviewInput!): ScheduleReview! + + """ + Creates a SectionItem within a Section. + """ + createSectionItem(data: CreateSectionItemInput!): SectionItem! } """ diff --git a/servers/curated-corpus-api/src/admin/resolvers/index.ts b/servers/curated-corpus-api/src/admin/resolvers/index.ts index 2bff9214..d4a16396 100644 --- a/servers/curated-corpus-api/src/admin/resolvers/index.ts +++ b/servers/curated-corpus-api/src/admin/resolvers/index.ts @@ -29,6 +29,7 @@ import { } from '../../database/queries'; import { getOpenGraphFields } from './queries/OpenGraphFields'; import { hasTrustedDomain } from './queries/ApprovedItem/hasTrustedDomain'; +import { createSectionItem } from './mutations/SectionItem'; export const resolvers = { // The custom scalars from GraphQL-Scalars that we find useful. @@ -84,6 +85,10 @@ export const resolvers = { createdAt: UnixTimestampResolver, updatedAt: UnixTimestampResolver, }, + SectionItem: { + createdAt: UnixTimestampResolver, + updatedAt: UnixTimestampResolver, + }, // The queries available Query: { approvedCorpusItemByExternalId: getApprovedItemByExternalId, @@ -106,5 +111,6 @@ export const resolvers = { uploadApprovedCorpusItemImage: uploadApprovedItemImage, updateApprovedCorpusItemGrade: updateApprovedItemGrade, createScheduleReview: createScheduleReview, + createSectionItem: createSectionItem, }, }; diff --git a/servers/curated-corpus-api/src/admin/resolvers/mutations/SectionItem/createSectionItem.integration.ts b/servers/curated-corpus-api/src/admin/resolvers/mutations/SectionItem/createSectionItem.integration.ts new file mode 100644 index 00000000..97e9ca87 --- /dev/null +++ b/servers/curated-corpus-api/src/admin/resolvers/mutations/SectionItem/createSectionItem.integration.ts @@ -0,0 +1,214 @@ +import { print } from 'graphql'; +import request from 'supertest'; + +import { ApolloServer } from '@apollo/server'; +import { PrismaClient, Section } from '.prisma/client'; + +import { ScheduledItemSource, CreateSectionItemApiInput } from 'content-common'; + +import { client } from '../../../../database/client'; +import { ApprovedItem } from '../../../../database/types'; + +import { + clearDb, + createSectionHelper, + createApprovedItemHelper, +} from '../../../../test/helpers'; +import { CREATE_SECTION_ITEM } from '../sample-mutations.gql'; +import { MozillaAccessGroup } from 'content-common'; +import { startServer } from '../../../../express'; +import { IAdminContext } from '../../../context'; + +describe('mutations: SectionItem (createSectionItem)', () => { + let app: Express.Application; + let db: PrismaClient; + let graphQLUrl: string; + let input: CreateSectionItemApiInput; + let server: ApolloServer; + let section: Section; + let approvedItem: ApprovedItem; + + const headers = { + name: 'Test User', + username: 'test.user@test.com', + groups: `group1,group2,${MozillaAccessGroup.SCHEDULED_SURFACE_CURATOR_FULL}`, + }; + + beforeAll(async () => { + // port 0 tells express to dynamically assign an available port + ({ app, adminServer: server, adminUrl: graphQLUrl } = await startServer(0)); + db = client(); + }); + + afterAll(async () => { + await server.stop(); + await clearDb(db); + await db.$disconnect(); + }); + + beforeEach(async () => { + // we need a Section and an ApprovedItem to create a SectionItem + section = await createSectionHelper(db, { + createSource: ScheduledItemSource.ML, + }); + + approvedItem = await createApprovedItemHelper(db, { + title: '10 Reasons You Should Quit Social Media', + }); + }); + + it('should create a SectionItem if user has full access', async () => { + input = { + sectionExternalId: section.externalId, + approvedItemExternalId: approvedItem.externalId, + rank: 1, + }; + + const result = await request(app) + .post(graphQLUrl) + .set(headers) + .send({ + query: print(CREATE_SECTION_ITEM), + variables: { data: input }, + }); + + expect(result.body.errors).toBeUndefined(); + expect(result.body.data).not.toBeNull(); + + // the rank should be as set above + expect(result.body.data?.createSectionItem.rank).toEqual(1); + // the associated approvedItem should be there... + expect(result.body.data?.createSectionItem.approvedItem).not.toBeNull(); + // ...and should match the approvedItem from the input + expect(result.body.data?.createSectionItem.approvedItem.externalId).toEqual( + input.approvedItemExternalId, + ); + }); + + it('should create a SectionItem without optional properties', async () => { + // `rank` is the only optional property - omitting below + input = { + sectionExternalId: section.externalId, + approvedItemExternalId: approvedItem.externalId, + }; + + const result = await request(app) + .post(graphQLUrl) + .set(headers) + .send({ + query: print(CREATE_SECTION_ITEM), + variables: { data: input }, + }); + + expect(result.body.errors).toBeUndefined(); + expect(result.body.data).not.toBeNull(); + + // the rank should be null + expect(result.body.data?.createSectionItem.rank).toBeNull(); + // the associated approvedItem should be there... + expect(result.body.data?.createSectionItem.approvedItem).not.toBeNull(); + // ...and should match the approvedItem from the input + expect(result.body.data?.createSectionItem.approvedItem.externalId).toEqual( + input.approvedItemExternalId, + ); + }); + + it('should create a duplicate SectionItem', async () => { + // this test explicitly demonstrates that we do not have any restrictions + // on creating a duplicate SectionItem on the same Section. + // this is because for initial implementation, only ML-generated Sections + // and SectionItems will be created. in this flow, whenever ML creates a + // new set of SectionItems for a Section, we will first deactivate any + // existing SectionItems. + + // we'll use this input for creating two idential SectionItems + input = { + sectionExternalId: section.externalId, + approvedItemExternalId: approvedItem.externalId, + rank: 1, + }; + + // create the first SectionItem + const result1 = await request(app) + .post(graphQLUrl) + .set(headers) + .send({ + query: print(CREATE_SECTION_ITEM), + variables: { data: input }, + }); + + // should not have any errors + expect(result1.body.errors).toBeUndefined(); + expect(result1.body.data).not.toBeNull(); + expect(result1.body.data?.createSectionItem).not.toBeNull(); + + const si1 = result1.body.data?.createSectionItem; + + // create the second SectionItem + const result2 = await request(app) + .post(graphQLUrl) + .set(headers) + .send({ + query: print(CREATE_SECTION_ITEM), + variables: { data: input }, + }); + + // should not have any errors + expect(result2.body.errors).toBeUndefined(); + expect(result2.body.data).not.toBeNull(); + expect(result2.body.data?.createSectionItem).not.toBeNull(); + + const si2 = result2.body.data?.createSectionItem; + + // the two SectionItems should have different externalIds + expect(si1.externalId).not.toEqual(si2.externalId); + }); + + it('should fail to create a SectionItem if the Section externalId is invalid', async () => { + input = { + sectionExternalId: 'aTotallyLegitimateId', + approvedItemExternalId: approvedItem.externalId, + }; + + const result = await request(app) + .post(graphQLUrl) + .set(headers) + .send({ + query: print(CREATE_SECTION_ITEM), + variables: { data: input }, + }); + + // we should have a NOT_FOUND error + expect(result.body.errors).not.toBeUndefined(); + expect(result.body.errors?.[0].extensions?.code).toEqual('NOT_FOUND'); + + // error message should reference the invalid Section externalId + expect(result.body.errors?.[0].message).toContain( + `Cannot create a section item: Section with id "aTotallyLegitimateId" does not exist.`, + ); + }); + + it('should fail to create a SectionItem if the ApprovedItem externalId is invalid', async () => { + input = { + sectionExternalId: section.externalId, + approvedItemExternalId: 'aTotallyLegitimateId', + }; + + const result = await request(app) + .post(graphQLUrl) + .set(headers) + .send({ + query: print(CREATE_SECTION_ITEM), + variables: { data: input }, + }); + + // we should have a NOT_FOUND error + expect(result.body.errors).not.toBeUndefined(); + expect(result.body.errors?.[0].extensions?.code).toEqual('NOT_FOUND'); + + // error message should reference the invalid Section externalId + expect(result.body.errors?.[0].message).toContain( + `Cannot create a section item: ApprovedItem with id "aTotallyLegitimateId" does not exist.`, + ); + }); +}); diff --git a/servers/curated-corpus-api/src/admin/resolvers/mutations/SectionItem/index.ts b/servers/curated-corpus-api/src/admin/resolvers/mutations/SectionItem/index.ts new file mode 100644 index 00000000..baad3b86 --- /dev/null +++ b/servers/curated-corpus-api/src/admin/resolvers/mutations/SectionItem/index.ts @@ -0,0 +1,50 @@ +import { AuthenticationError, NotFoundError } from '@pocket-tools/apollo-utils'; + +import { createSectionItem as dbCreateSectionItem } from '../../../../database/mutations'; +import { SectionItem } from '../../../../database/types'; +import { ACCESS_DENIED_ERROR } from '../../../../shared/types'; +import { IAdminContext } from '../../../context'; + +/** + * Adds a SectionItem to a Section. + * + * @param parent + * @param data + * @param context + */ +export async function createSectionItem( + parent, + { data }, + context: IAdminContext, +): Promise { + // find the targeted Section so we can verify user has write access to its Scheduled Surface + const section = await context.db.section.findUnique({ + where: { externalId: data.sectionExternalId }, + }); + + if (!section) { + throw new NotFoundError( + `Cannot create a section item: Section with id "${data.sectionExternalId}" does not exist.`, + ); + } + + const scheduledSurfaceGuid = section.scheduledSurfaceGuid; + + // Check if the user can execute this mutation. + if (!context.authenticatedUser.canWriteToSurface(scheduledSurfaceGuid)) { + throw new AuthenticationError(ACCESS_DENIED_ERROR); + } + + const sectionItem = await dbCreateSectionItem(context.db, { + approvedItemExternalId: data.approvedItemExternalId, + rank: data.rank, + sectionId: section.id, + }); + + // TODO: emit creation event to a data pipeline + // as of this writing (2025-01-09), we are navigating the migration from + // snowplow & snowflake to glean & bigquery. we are awaiting a decision + // on the best path forward for our data pipeline. + + return sectionItem; +} diff --git a/servers/curated-corpus-api/src/admin/resolvers/mutations/sample-mutations.gql.ts b/servers/curated-corpus-api/src/admin/resolvers/mutations/sample-mutations.gql.ts index 8f8147fb..66e02980 100644 --- a/servers/curated-corpus-api/src/admin/resolvers/mutations/sample-mutations.gql.ts +++ b/servers/curated-corpus-api/src/admin/resolvers/mutations/sample-mutations.gql.ts @@ -1,5 +1,8 @@ import { gql } from 'graphql-tag'; -import { RejectedItemData } from '../../../shared/fragments.gql'; +import { + RejectedItemData, + SectionItemData, +} from '../../../shared/fragments.gql'; import { AdminCuratedItemData, AdminScheduledItemData, @@ -100,3 +103,12 @@ export const CREATE_SCHEDULE_REVIEW = gql` } ${AdminScheduleReviewData} `; + +export const CREATE_SECTION_ITEM = gql` + mutation createSectionItem($data: CreateSectionItemInput!) { + createSectionItem(data: $data) { + ...SectionItemData + } + } + ${SectionItemData} +`; diff --git a/servers/curated-corpus-api/src/database/mutations/SectionItem.integration.ts b/servers/curated-corpus-api/src/database/mutations/SectionItem.integration.ts new file mode 100644 index 00000000..347d7705 --- /dev/null +++ b/servers/curated-corpus-api/src/database/mutations/SectionItem.integration.ts @@ -0,0 +1,42 @@ +import { PrismaClient } from '.prisma/client'; + +import { client } from '../client'; +import { clearDb } from '../../test/helpers'; +import { createSectionItem } from './SectionItem'; +import { createApprovedItemHelper } from '../../test/helpers'; +import { createSectionHelper } from '../../test/helpers'; + +describe('ScheduleReview', () => { + let db: PrismaClient; + + beforeAll(async () => { + db = client(); + await clearDb(db); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + beforeEach(async () => { + await clearDb(db); + }); + + describe('createSectionItem', () => { + it('should create a SectionItem', async () => { + const approvedItem = await createApprovedItemHelper(db, { + title: 'Fake Item!', + }); + + const section = await createSectionHelper(db, {}); + + const result = await createSectionItem(db, { + sectionId: section.id, + approvedItemExternalId: approvedItem.externalId, + }); + + expect(result.sectionId).toEqual(section.id); + expect(result.approvedItemId).toEqual(approvedItem.id); + }); + }); +}); diff --git a/servers/curated-corpus-api/src/database/mutations/SectionItem.ts b/servers/curated-corpus-api/src/database/mutations/SectionItem.ts new file mode 100644 index 00000000..a8250bd5 --- /dev/null +++ b/servers/curated-corpus-api/src/database/mutations/SectionItem.ts @@ -0,0 +1,56 @@ +import { NotFoundError } from '@pocket-tools/apollo-utils'; + +import { PrismaClient } from '.prisma/client'; + +import { CreateSectionItemInput, SectionItem } from '../types'; + +/** + * This mutation adds an item to a section + * + * @param db + * @param data + * @param username + */ +export async function createSectionItem( + db: PrismaClient, + data: CreateSectionItemInput, +): Promise { + // we verify the Section/sectionId in the upstream resolver, so no need to + // do so again here + const { sectionId, approvedItemExternalId, rank } = data; + + // make sure the targeted ApprovedItem exists + const approvedItem = await db.approvedItem.findUnique({ + where: { externalId: approvedItemExternalId }, + }); + + if (!approvedItem) { + throw new NotFoundError( + `Cannot create a section item: ApprovedItem with id "${approvedItemExternalId}" does not exist.`, + ); + } + + const createData = { + approvedItemId: approvedItem.id, + sectionId, + rank, + }; + + return await db.sectionItem.create({ + data: createData, + // for the initial implementation (ML-generated Sections only), i don't + // think we have a need for the associated ApprovedItem. however, when we + // allow editors to create Sections, we most certainly will. usage on this + // mutation shouldn't be so high that we need to optimize db selects, so + // leaving this in. + include: { + approvedItem: { + include: { + authors: { + orderBy: [{ sortOrder: 'asc' }], + }, + }, + }, + }, + }); +} diff --git a/servers/curated-corpus-api/src/database/mutations/index.ts b/servers/curated-corpus-api/src/database/mutations/index.ts index 742e1937..735400e1 100644 --- a/servers/curated-corpus-api/src/database/mutations/index.ts +++ b/servers/curated-corpus-api/src/database/mutations/index.ts @@ -10,3 +10,4 @@ export { moveScheduledItemToBottom, } from './ScheduledItem'; export { createScheduleReview } from './ScheduleReview'; +export { createSectionItem } from './SectionItem'; diff --git a/servers/curated-corpus-api/src/database/types.ts b/servers/curated-corpus-api/src/database/types.ts index 41e313bc..86558f2d 100644 --- a/servers/curated-corpus-api/src/database/types.ts +++ b/servers/curated-corpus-api/src/database/types.ts @@ -4,6 +4,7 @@ import { CuratedStatus, ScheduledItem as ScheduledItemModel, ScheduleReview, + SectionItem as SectionItemModel, } from '.prisma/client'; import { ApprovedItemAuthor, @@ -110,10 +111,20 @@ export type CreateScheduleReviewInput = { scheduledDate: string; }; +export type CreateSectionItemInput = { + sectionId: number; + approvedItemExternalId: string; + rank?: number; +}; + export type ApprovedItem = ApprovedItemModel & { authors: ApprovedItemAuthor[]; }; +export type SectionItem = SectionItemModel & { + approvedItem: ApprovedItem; +}; + /** * CorpusTargetType probably makes more sense to be a union of all Pocket types * or entities. An incremental step in that direction was chosen. If we want to diff --git a/servers/curated-corpus-api/src/shared/fragments.gql.ts b/servers/curated-corpus-api/src/shared/fragments.gql.ts index 2051cb91..382f4ffa 100644 --- a/servers/curated-corpus-api/src/shared/fragments.gql.ts +++ b/servers/curated-corpus-api/src/shared/fragments.gql.ts @@ -59,3 +59,16 @@ export const ScheduledItemData = gql` } ${CuratedItemData} `; + +export const SectionItemData = gql` + fragment SectionItemData on SectionItem { + externalId + approvedItem { + ...CuratedItemData + } + rank + createdAt + updatedAt + } + ${CuratedItemData} +`; diff --git a/servers/curated-corpus-api/src/test/helpers/clearDb.ts b/servers/curated-corpus-api/src/test/helpers/clearDb.ts index 3ed0c4d3..9a1973d1 100644 --- a/servers/curated-corpus-api/src/test/helpers/clearDb.ts +++ b/servers/curated-corpus-api/src/test/helpers/clearDb.ts @@ -9,11 +9,11 @@ import { PrismaClient } from '.prisma/client'; export async function clearDb(prisma: PrismaClient): Promise { // Delete data from each table, starting with tables that contain foreign keys await prisma.scheduledItem.deleteMany({}); + await prisma.sectionItem.deleteMany({}); + await prisma.section.deleteMany({}); await prisma.approvedItem.deleteMany({}); await prisma.rejectedCuratedCorpusItem.deleteMany({}); await prisma.trustedDomain.deleteMany({}); await prisma.scheduleReview.deleteMany({}); await prisma.excludedDomain.deleteMany({}); - await prisma.sectionItem.deleteMany({}); - await prisma.section.deleteMany({}); } diff --git a/servers/curated-corpus-api/src/test/helpers/createSectionHelper.integration.ts b/servers/curated-corpus-api/src/test/helpers/createSectionHelper.integration.ts new file mode 100644 index 00000000..b7d24210 --- /dev/null +++ b/servers/curated-corpus-api/src/test/helpers/createSectionHelper.integration.ts @@ -0,0 +1,47 @@ +import { PrismaClient, Section } from '.prisma/client'; + +import { ScheduledItemSource, ScheduledSurfacesEnum } from 'content-common'; + +import { clearDb } from './clearDb'; +import { + createSectionHelper, + CreateSectionHelperOptionalInput, +} from './createSectionHelper'; + +const db = new PrismaClient(); + +describe('createSectionHelper', () => { + beforeEach(async () => { + await clearDb(db); + }); + + afterAll(async () => { + await db.$disconnect(); + }); + + it('should create a Section with no props supplied', async () => { + const data: CreateSectionHelperOptionalInput = {}; + + const section: Section = await createSectionHelper(db, data); + + // just make sure a record was created + expect(section.externalId).not.toBeFalsy(); + }); + + it('should create a Section with all props supplied', async () => { + const data: CreateSectionHelperOptionalInput = { + createSource: ScheduledItemSource.ML, + externalId: 'AnExternalIdFromML', + scheduledSurfaceGuid: ScheduledSurfacesEnum.NEW_TAB_EN_US, + title: 'How to Build Community', + }; + + const section: Section = await createSectionHelper(db, data); + + // make sure props specified make it to the db + expect(section.createSource).toEqual(data.createSource); + expect(section.externalId).toEqual(data.externalId); + expect(section.scheduledSurfaceGuid).toEqual(data.scheduledSurfaceGuid); + expect(section.title).toEqual(data.title); + }); +}); diff --git a/servers/curated-corpus-api/src/test/helpers/createSectionHelper.ts b/servers/curated-corpus-api/src/test/helpers/createSectionHelper.ts new file mode 100644 index 00000000..ea7241ea --- /dev/null +++ b/servers/curated-corpus-api/src/test/helpers/createSectionHelper.ts @@ -0,0 +1,46 @@ +import { Prisma, PrismaClient, Section } from '.prisma/client'; +import { faker } from '@faker-js/faker'; + +import { ScheduledSurfacesEnum, ScheduledItemSource } from 'content-common'; + +// optional information you can provide when creating an section +export interface CreateSectionHelperOptionalInput { + createSource?: ScheduledItemSource; + // externalId can be provided, but if not will be generated by prisma on insert + externalId?: string; + scheduledSurfaceGuid?: ScheduledSurfacesEnum; + title?: string; +} + +// create an array of scheduled surface guids for random selection +const scheduledSurfaceGuids = Object.values(ScheduledSurfacesEnum).map( + (value) => value, +); + +/** + * A helper function that creates a sample section for testing or local development. + * @param prisma + * @param data + */ +export async function createSectionHelper( + prisma: PrismaClient, + data: CreateSectionHelperOptionalInput, +): Promise
{ + const createSectionDefaults = { + createSource: faker.helpers.arrayElement([ + ScheduledItemSource.MANUAL, + ScheduledItemSource.ML, + ]), + scheduledSurfaceGuid: faker.helpers.arrayElement(scheduledSurfaceGuids), + title: faker.lorem.sentence(10), + }; + + const inputs: Prisma.SectionCreateInput = { + ...createSectionDefaults, + ...data, + }; + + return await prisma.section.create({ + data: inputs, + }); +} diff --git a/servers/curated-corpus-api/src/test/helpers/index.ts b/servers/curated-corpus-api/src/test/helpers/index.ts index b3698776..537f3357 100644 --- a/servers/curated-corpus-api/src/test/helpers/index.ts +++ b/servers/curated-corpus-api/src/test/helpers/index.ts @@ -4,3 +4,4 @@ export { createExcludedDomainHelper } from './createExcludedDomainHelper'; export { createScheduledItemHelper } from './createScheduledItemHelper'; export { createRejectedCuratedCorpusItemHelper } from './createRejectedCuratedCorpusItemHelper'; export { createScheduleReviewHelper } from './createScheduleReviewHelper'; +export { createSectionHelper } from './createSectionHelper';