From 076aa3e673d0392f3901d641a7b671ff34bca539 Mon Sep 17 00:00:00 2001 From: Kat Schelonka <34227334+kschelonka@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:04:35 -0800 Subject: [PATCH] feat(notes): update document content (#989) [POCKET-10862] --- servers/notes-api/schema.graphql | 25 +++- .../notes-api/src/__generated__/graphql.d.ts | 32 ++++- servers/notes-api/src/apollo/resolvers.ts | 3 + .../notes-api/src/datasources/NoteService.ts | 49 ++++++-- servers/notes-api/src/models/Note.ts | 31 +++++ .../mutations/editNoteContent.integration.ts | 113 ++++++++++++++++++ .../src/test/operations/mutations.ts | 9 ++ 7 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 servers/notes-api/src/test/mutations/editNoteContent.integration.ts diff --git a/servers/notes-api/schema.graphql b/servers/notes-api/schema.graphql index 5af8c4c18..08ef7be9a 100644 --- a/servers/notes-api/schema.graphql +++ b/servers/notes-api/schema.graphql @@ -141,7 +141,23 @@ input EditNoteTitleInput { """ updatedAt: ISOString } - +""" +Input for editing the content of a Note (user-generated) +""" +input EditNoteContentInput { + """ + The ID of the note to edit + """ + noteId: ID! + """ + JSON representation of a ProseMirror document + """ + docContent: ProseMirrorJson! + """ + The time this update was made (defaults to server time) + """ + updatedAt: ISOString +} type Mutation { """ @@ -160,4 +176,11 @@ type Mutation { errors array. """ editNoteTitle(input: EditNoteTitleInput!): Note @requiresScopes(scopes: [["ROLE_USER"]]) + """ + Edit the content of a Note. + If the Note does not exist or is inaccessible for the current user, + response will be null and a NOT_FOUND error will be included in the + errors array. + """ + editNoteContent(input: EditNoteContentInput!): Note @requiresScopes(scopes: [["ROLE_USER"]]) } diff --git a/servers/notes-api/src/__generated__/graphql.d.ts b/servers/notes-api/src/__generated__/graphql.d.ts index 7000d909c..da72b76dd 100644 --- a/servers/notes-api/src/__generated__/graphql.d.ts +++ b/servers/notes-api/src/__generated__/graphql.d.ts @@ -78,6 +78,16 @@ export type CreateNoteInput = { title?: InputMaybe; }; +/** Input for editing the content of a Note (user-generated) */ +export type EditNoteContentInput = { + /** JSON representation of a ProseMirror document */ + docContent: Scalars['ProseMirrorJson']['input']; + /** The ID of the note to edit */ + noteId: Scalars['ID']['input']; + /** The time this update was made (defaults to server time) */ + updatedAt?: InputMaybe; +}; + export type EditNoteTitleInput = { /** The ID of the note to edit */ id: Scalars['ID']['input']; @@ -99,7 +109,19 @@ export type Mutation = { * selected by a user. */ createNoteFromQuote: Note; - /** Edit the title of a Note. */ + /** + * Edit the content of a Note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + editNoteContent?: Maybe; + /** + * Edit the title of a Note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ editNoteTitle?: Maybe; }; @@ -114,6 +136,11 @@ export type MutationCreateNoteFromQuoteArgs = { }; +export type MutationEditNoteContentArgs = { + input: EditNoteContentInput; +}; + + export type MutationEditNoteTitleArgs = { input: EditNoteTitleInput; }; @@ -261,6 +288,7 @@ export type ResolversTypes = ResolversObject<{ ID: ResolverTypeWrapper; String: ResolverTypeWrapper; CreateNoteInput: CreateNoteInput; + EditNoteContentInput: EditNoteContentInput; EditNoteTitleInput: EditNoteTitleInput; ISOString: ResolverTypeWrapper; Markdown: ResolverTypeWrapper; @@ -279,6 +307,7 @@ export type ResolversParentTypes = ResolversObject<{ ID: Scalars['ID']['output']; String: Scalars['String']['output']; CreateNoteInput: CreateNoteInput; + EditNoteContentInput: EditNoteContentInput; EditNoteTitleInput: EditNoteTitleInput; ISOString: Scalars['ISOString']['output']; Markdown: Scalars['Markdown']['output']; @@ -302,6 +331,7 @@ export interface MarkdownScalarConfig extends GraphQLScalarTypeConfig = ResolversObject<{ createNote?: Resolver>; createNoteFromQuote?: Resolver>; + editNoteContent?: Resolver, ParentType, ContextType, RequireFields>; editNoteTitle?: Resolver, ParentType, ContextType, RequireFields>; }>; diff --git a/servers/notes-api/src/apollo/resolvers.ts b/servers/notes-api/src/apollo/resolvers.ts index d68874c5d..3343ddb3a 100644 --- a/servers/notes-api/src/apollo/resolvers.ts +++ b/servers/notes-api/src/apollo/resolvers.ts @@ -18,5 +18,8 @@ export const resolvers: Resolvers = { editNoteTitle(root, { input }, context) { return context.NoteModel.editTitle(input); }, + editNoteContent(root, { input }, context) { + return context.NoteModel.editContent(input); + }, }, }; diff --git a/servers/notes-api/src/datasources/NoteService.ts b/servers/notes-api/src/datasources/NoteService.ts index 4e709e184..860ba59b7 100644 --- a/servers/notes-api/src/datasources/NoteService.ts +++ b/servers/notes-api/src/datasources/NoteService.ts @@ -1,6 +1,6 @@ -import { Insertable } from 'kysely'; +import { Insertable, UpdateQueryBuilder, UpdateResult } from 'kysely'; import { IContext } from '../apollo/context'; -import { Note as NoteEntity } from '../__generated__/db'; +import { DB, Note as NoteEntity } from '../__generated__/db'; /** * Database methods for retrieving and creating Notes @@ -49,12 +49,26 @@ export class NotesService { .executeTakeFirstOrThrow(); return result; } + + /** + * Basic update builder with where statements for userId + * and noteId baked in (avoids some repetition) + */ + private updateBase( + noteId: string, + ): UpdateQueryBuilder { + return this.context.db + .updateTable('Note') + .where('noteId', '=', noteId) + .where('userId', '=', this.context.userId); + } /** * Update the title field in a Note * @param noteId the UUID of the Note entity to update * @param title the new title (can be empty string) * @param updatedAt when the update was performed - * @returns + * @returns the updated Note entity + * @throws error if the query returned no result */ async updateTitle( noteId: string, @@ -65,11 +79,32 @@ export class NotesService { updatedAt != null ? { title, updatedAt } : { title, updatedAt: new Date(Date.now()) }; - const result = await this.context.db - .updateTable('Note') + const result = await this.updateBase(noteId) + .set(setUpdate) + .returningAll() + .executeTakeFirstOrThrow(); + return result; + } + /** + * Update the docContent field in a Note + * @param noteId the UUID of the Note entity to update + * @param docContent JSON representation of ProseMirror document + * (pre-validated). + * @param updatedAt when the update was performed + * @returns the updated Note entity + * @throws error if the query returned no result + */ + async updateDocContent( + noteId: string, + docContent: any, + updatedAt?: Date | string | null, + ) { + const setUpdate = + updatedAt != null + ? { docContent, updatedAt } + : { docContent, updatedAt: new Date(Date.now()) }; + const result = await this.updateBase(noteId) .set(setUpdate) - .where('noteId', '=', noteId) - .where('userId', '=', this.context.userId) .returningAll() .executeTakeFirstOrThrow(); return result; diff --git a/servers/notes-api/src/models/Note.ts b/servers/notes-api/src/models/Note.ts index 4bc28b48c..60ea4897c 100644 --- a/servers/notes-api/src/models/Note.ts +++ b/servers/notes-api/src/models/Note.ts @@ -4,6 +4,7 @@ import { CreateNoteInput, CreateNoteFromQuoteInput, EditNoteTitleInput, + EditNoteContentInput, } from '../__generated__/graphql'; import { Note as NoteEntity } from '../__generated__/db'; import { Insertable, NoResultError, Selectable } from 'kysely'; @@ -89,6 +90,7 @@ export class NoteModel { */ async create(input: CreateNoteInput) { try { + // TODO // At some point do more validation // We can move this to a scalar const docContent = JSON.parse(input.docContent); @@ -171,4 +173,33 @@ export class NoteModel { } } } + /** + * Edit a note's content + */ + async editContent(input: EditNoteContentInput) { + try { + // TODO + // At some point do more validation + // We can move this to a scalar + const docContent = JSON.parse(input.docContent); + const result = await this.service.updateDocContent( + input.noteId, + docContent, + input.updatedAt, + ); + return this.toGraphql(result); + } catch (error) { + if (error instanceof NoResultError) { + throw new NotFoundError( + `Note with id=${input.noteId} does not exist or is forbidden`, + ); + } else if (error instanceof SyntaxError) { + throw new UserInputError( + `Received malformed JSON for docContent: ${error.message}`, + ); + } else { + throw error; + } + } + } } diff --git a/servers/notes-api/src/test/mutations/editNoteContent.integration.ts b/servers/notes-api/src/test/mutations/editNoteContent.integration.ts new file mode 100644 index 000000000..0dfce9c0e --- /dev/null +++ b/servers/notes-api/src/test/mutations/editNoteContent.integration.ts @@ -0,0 +1,113 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { EDIT_NOTE_CONTENT } from '../operations'; +import { db } from '../../datasources/db'; +import { sql } from 'kysely'; +import { Chance } from 'chance'; +import { Note as NoteFaker } from '../fakes/Note'; +import { EditNoteContentInput } from '../../__generated__/graphql'; +import basicText from '../documents/basicText.json'; + +let app: Application; +let server: ApolloServer; +let graphQLUrl: string; +const chance = new Chance(); +const notes = [...Array(4).keys()].map((_) => NoteFaker(chance)); + +beforeAll(async () => { + // port 0 tells express to dynamically assign an available port + ({ app, server, url: graphQLUrl } = await startServer(0)); + await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); + await db + .insertInto('Note') + .values(notes) + .returning(['noteId', 'userId']) + .execute(); +}); +afterAll(async () => { + await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); + await server.stop(); + await db.destroy(); +}); + +describe('note', () => { + it('edits a note content with a timestamp', async () => { + const now = new Date(Date.now()); + const { userId, noteId } = notes[0]; + + const input: EditNoteContentInput = { + noteId, + docContent: JSON.stringify(basicText), + updatedAt: now.toISOString(), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: EDIT_NOTE_CONTENT, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.editNoteContent).toMatchObject({ + docContent: expect.toBeString(), + updatedAt: now.toISOString(), + }); + // The keys get reordered so we have to deeply compare the + // JSON-serialized results + const receivedDoc = res.body.data?.editNoteContent?.docContent; + expect(receivedDoc).not.toBeNil(); + expect(JSON.parse(receivedDoc)).toStrictEqual(basicText); + }); + it('edits a note title without a timestamp', async () => { + const now = new Date(Date.now()); + const { userId, noteId } = notes[1]; + const input: EditNoteContentInput = { + noteId, + docContent: JSON.stringify(basicText), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: EDIT_NOTE_CONTENT, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.editNoteContent).toMatchObject({ + docContent: expect.toBeString(), + updatedAt: expect.toBeDateString(), + }); + const updatedAt = new Date(res.body.data?.editNoteContent?.updatedAt); + expect(updatedAt.getTime() - now.getTime()).toBeLessThanOrEqual(10000); // within 10 seconds of when this test started + // The keys get reordered so we have to deeply compare the + // JSON-serialized results + const receivedDoc = res.body.data?.editNoteContent?.docContent; + expect(receivedDoc).not.toBeNil(); + expect(JSON.parse(receivedDoc)).toStrictEqual(basicText); + }); + it('includes not found error for nonexistent note', async () => { + const input: EditNoteContentInput = { + noteId: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', + docContent: JSON.stringify(basicText), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: EDIT_NOTE_CONTENT, variables: { input } }); + expect(res.body.errors).toBeArrayOfSize(1); + expect(res.body.errors[0].extensions.code).toEqual('NOT_FOUND'); + }); + it('throws error for bad JSON', async () => { + const { userId, noteId } = notes[2]; + const input: EditNoteContentInput = { + noteId, + docContent: "{ 'bad': 'json'", + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: EDIT_NOTE_CONTENT, variables: { input } }); + + expect(res.body.errors).toBeArrayOfSize(1); + expect(res.body.errors[0].extensions.code).toEqual('BAD_USER_INPUT'); + expect(res.body.errors[0].message).toMatch( + 'Received malformed JSON for docContent', + ); + }); +}); diff --git a/servers/notes-api/src/test/operations/mutations.ts b/servers/notes-api/src/test/operations/mutations.ts index 2531716d8..a3a96e09c 100644 --- a/servers/notes-api/src/test/operations/mutations.ts +++ b/servers/notes-api/src/test/operations/mutations.ts @@ -44,3 +44,12 @@ export const EDIT_NOTE_TITLE = print(gql` } } `); + +export const EDIT_NOTE_CONTENT = print(gql` + ${NoteFragment} + mutation EditNoteContent($input: EditNoteContentInput!) { + editNoteContent(input: $input) { + ...NoteFields + } + } +`);