Skip to content

Commit

Permalink
feat: add corpus admin mutation to create a SectionItem (#255)
Browse files Browse the repository at this point in the history
* feat: add corpus admin mutation to create a SectionItem

- adds all the necessary helpers, types, and tests as well
  • Loading branch information
jpetto authored Jan 14, 2025
1 parent 8b9434b commit 4a2b722
Show file tree
Hide file tree
Showing 15 changed files with 562 additions and 3 deletions.
7 changes: 7 additions & 0 deletions packages/content-common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -217,6 +223,7 @@ export type ScheduledSurface = {
prospectTypes: ProspectType[];
accessGroup: string;
};

export const ScheduledSurfaces: ScheduledSurface[] = [
{
name: 'New Tab (en-US)',
Expand Down
53 changes: 53 additions & 0 deletions servers/curated-corpus-api/schema-admin.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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!
}

"""
Expand Down
6 changes: 6 additions & 0 deletions servers/curated-corpus-api/src/admin/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -84,6 +85,10 @@ export const resolvers = {
createdAt: UnixTimestampResolver,
updatedAt: UnixTimestampResolver,
},
SectionItem: {
createdAt: UnixTimestampResolver,
updatedAt: UnixTimestampResolver,
},
// The queries available
Query: {
approvedCorpusItemByExternalId: getApprovedItemByExternalId,
Expand All @@ -106,5 +111,6 @@ export const resolvers = {
uploadApprovedCorpusItemImage: uploadApprovedItemImage,
updateApprovedCorpusItemGrade: updateApprovedItemGrade,
createScheduleReview: createScheduleReview,
createSectionItem: createSectionItem,
},
};
Original file line number Diff line number Diff line change
@@ -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<IAdminContext>;
let section: Section;
let approvedItem: ApprovedItem;

const headers = {
name: 'Test User',
username: '[email protected]',
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.`,
);
});
});
Original file line number Diff line number Diff line change
@@ -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<SectionItem> {
// 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;
}
Loading

0 comments on commit 4a2b722

Please sign in to comment.