From 7d37623e555e6632df3c9a85eaa691b6ab770c8c Mon Sep 17 00:00:00 2001 From: Kat Schelonka <34227334+kschelonka@users.noreply.github.com> Date: Wed, 4 Dec 2024 13:22:03 -0800 Subject: [PATCH] feat(notes): edit title of a note (#988) [POCKET-10863] --- servers/notes-api/schema.graphql | 19 +++++ .../notes-api/src/__generated__/graphql.d.ts | 24 +++++- servers/notes-api/src/apollo/resolvers.ts | 3 + .../notes-api/src/datasources/NoteService.ts | 25 ++++++ servers/notes-api/src/models/Note.ts | 26 +++++- .../mutations/editNoteTitle.integration.ts | 83 +++++++++++++++++++ .../src/test/operations/mutations.ts | 9 ++ 7 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 servers/notes-api/src/test/mutations/editNoteTitle.integration.ts diff --git a/servers/notes-api/schema.graphql b/servers/notes-api/schema.graphql index e8ae5e95c..5af8c4c18 100644 --- a/servers/notes-api/schema.graphql +++ b/servers/notes-api/schema.graphql @@ -130,6 +130,18 @@ input CreateNoteFromQuoteInput { createdAt: ISOString } +input EditNoteTitleInput { + """The ID of the note to edit""" + id: ID! + """The new title for the note (can be an empty string)""" + title: String! @constraint(maxLength: 100) + """ + When the update was made. If not provided, defaults to the server + time upon receiving request. + """ + updatedAt: ISOString +} + type Mutation { """ @@ -141,4 +153,11 @@ type Mutation { selected by a user. """ createNoteFromQuote(input: CreateNoteFromQuoteInput!): Note! @requiresScopes(scopes: [["ROLE_USER"]]) + """ + 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(input: EditNoteTitleInput!): 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 72a1a710f..7000d909c 100644 --- a/servers/notes-api/src/__generated__/graphql.d.ts +++ b/servers/notes-api/src/__generated__/graphql.d.ts @@ -46,7 +46,7 @@ export type CreateNoteFromQuoteInput = { * contains the formatted snipped text. This is used to seed * the initial Note document state, and will become editable. */ - quote: Scalars['String']['input']; + quote: Scalars['ProseMirrorJson']['input']; /** * The Web Resource where the quote is taken from. * This should always be sent by the client where possible, @@ -78,6 +78,18 @@ export type CreateNoteInput = { title?: InputMaybe; }; +export type EditNoteTitleInput = { + /** The ID of the note to edit */ + id: Scalars['ID']['input']; + /** The new title for the note (can be an empty string) */ + title: Scalars['String']['input']; + /** + * When the update was made. If not provided, defaults to the server + * time upon receiving request. + */ + updatedAt?: InputMaybe; +}; + export type Mutation = { __typename?: 'Mutation'; /** Create a new note, optionally with title and content */ @@ -87,6 +99,8 @@ export type Mutation = { * selected by a user. */ createNoteFromQuote: Note; + /** Edit the title of a Note. */ + editNoteTitle?: Maybe; }; @@ -99,6 +113,11 @@ export type MutationCreateNoteFromQuoteArgs = { input: CreateNoteFromQuoteInput; }; + +export type MutationEditNoteTitleArgs = { + input: EditNoteTitleInput; +}; + /** * A Note is an entity which may contain extracted components * from websites (clippings/snippets), user-generated rich text content, @@ -242,6 +261,7 @@ export type ResolversTypes = ResolversObject<{ ID: ResolverTypeWrapper; String: ResolverTypeWrapper; CreateNoteInput: CreateNoteInput; + EditNoteTitleInput: EditNoteTitleInput; ISOString: ResolverTypeWrapper; Markdown: ResolverTypeWrapper; Mutation: ResolverTypeWrapper<{}>; @@ -259,6 +279,7 @@ export type ResolversParentTypes = ResolversObject<{ ID: Scalars['ID']['output']; String: Scalars['String']['output']; CreateNoteInput: CreateNoteInput; + EditNoteTitleInput: EditNoteTitleInput; ISOString: Scalars['ISOString']['output']; Markdown: Scalars['Markdown']['output']; Mutation: {}; @@ -281,6 +302,7 @@ export interface MarkdownScalarConfig extends GraphQLScalarTypeConfig = ResolversObject<{ createNote?: Resolver>; createNoteFromQuote?: Resolver>; + editNoteTitle?: Resolver, ParentType, ContextType, RequireFields>; }>; export type NoteResolvers = ResolversObject<{ diff --git a/servers/notes-api/src/apollo/resolvers.ts b/servers/notes-api/src/apollo/resolvers.ts index c654d307e..d68874c5d 100644 --- a/servers/notes-api/src/apollo/resolvers.ts +++ b/servers/notes-api/src/apollo/resolvers.ts @@ -15,5 +15,8 @@ export const resolvers: Resolvers = { createNoteFromQuote(root, { input }, context) { return context.NoteModel.fromQuote(input); }, + editNoteTitle(root, { input }, context) { + return context.NoteModel.editTitle(input); + }, }, }; diff --git a/servers/notes-api/src/datasources/NoteService.ts b/servers/notes-api/src/datasources/NoteService.ts index 13ba33b32..4e709e184 100644 --- a/servers/notes-api/src/datasources/NoteService.ts +++ b/servers/notes-api/src/datasources/NoteService.ts @@ -49,4 +49,29 @@ export class NotesService { .executeTakeFirstOrThrow(); return result; } + /** + * 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 + */ + async updateTitle( + noteId: string, + title: string, + updatedAt?: Date | string | null, + ) { + const setUpdate = + updatedAt != null + ? { title, updatedAt } + : { title, updatedAt: new Date(Date.now()) }; + const result = await this.context.db + .updateTable('Note') + .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 15da5df0a..4bc28b48c 100644 --- a/servers/notes-api/src/models/Note.ts +++ b/servers/notes-api/src/models/Note.ts @@ -3,14 +3,15 @@ import { Note, CreateNoteInput, CreateNoteFromQuoteInput, + EditNoteTitleInput, } from '../__generated__/graphql'; import { Note as NoteEntity } from '../__generated__/db'; -import { Insertable, Selectable } from 'kysely'; +import { Insertable, NoResultError, Selectable } from 'kysely'; import { orderAndMap } from '../utils/dataloader'; import { IContext } from '../apollo/context'; import { NotesService } from '../datasources/NoteService'; import { ProseMirrorDoc, wrapDocInBlockQuote } from './ProseMirrorDoc'; -import { UserInputError } from '@pocket-tools/apollo-utils'; +import { NotFoundError, UserInputError } from '@pocket-tools/apollo-utils'; import { DatabaseError } from 'pg'; /** @@ -149,4 +150,25 @@ export class NoteModel { } } } + /** + * Edit a note's title + */ + async editTitle(input: EditNoteTitleInput) { + try { + const result = await this.service.updateTitle( + input.id, + input.title, + input.updatedAt, + ); + return this.toGraphql(result); + } catch (error) { + if (error instanceof NoResultError) { + throw new NotFoundError( + `Note with id=${input.id} does not exist or is forbidden`, + ); + } else { + throw error; + } + } + } } diff --git a/servers/notes-api/src/test/mutations/editNoteTitle.integration.ts b/servers/notes-api/src/test/mutations/editNoteTitle.integration.ts new file mode 100644 index 000000000..2128c3274 --- /dev/null +++ b/servers/notes-api/src/test/mutations/editNoteTitle.integration.ts @@ -0,0 +1,83 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { EDIT_NOTE_TITLE } from '../operations'; +import { db } from '../../datasources/db'; +import { sql } from 'kysely'; +import { Chance } from 'chance'; +import { Note as NoteFaker } from '../fakes/Note'; + +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 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 title with a timestamp', async () => { + const now = new Date(Date.now()); + const { userId, noteId } = notes[0]; + + const input = { + id: noteId, + title: 'Why I Stopped Worrying and Started Loving My Fungus', + updatedAt: now.toISOString(), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: EDIT_NOTE_TITLE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.editNoteTitle).toMatchObject({ + title: 'Why I Stopped Worrying and Started Loving My Fungus', + updatedAt: now.toISOString(), + }); + }); + it('edits a note title without a timestamp', async () => { + const now = new Date(Date.now()); + const { userId, noteId } = notes[1]; + const input = { + id: noteId, + title: `How To Talk To Your Kids About Bloodstone Rituals`, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: EDIT_NOTE_TITLE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.editNoteTitle).toMatchObject({ + title: `How To Talk To Your Kids About Bloodstone Rituals`, + updatedAt: expect.toBeDateString(), + }); + const updatedAt = new Date(res.body.data?.editNoteTitle?.updatedAt); + expect(updatedAt.getTime() - now.getTime()).toBeLessThanOrEqual(10000); // within 10 seconds of when this test started + }); + it('includes not found error for nonexistent note', async () => { + const input = { + id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', + title: `How To Talk To Your Kids About Bloodstone Rituals`, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: EDIT_NOTE_TITLE, variables: { input } }); + expect(res.body.errors).toBeArrayOfSize(1); + expect(res.body.errors[0].extensions.code).toEqual('NOT_FOUND'); + }); +}); diff --git a/servers/notes-api/src/test/operations/mutations.ts b/servers/notes-api/src/test/operations/mutations.ts index 3ab9790fd..2531716d8 100644 --- a/servers/notes-api/src/test/operations/mutations.ts +++ b/servers/notes-api/src/test/operations/mutations.ts @@ -35,3 +35,12 @@ export const CREATE_NOTE_QUOTE = print(gql` } } `); + +export const EDIT_NOTE_TITLE = print(gql` + ${NoteFragment} + mutation EditNoteTitle($input: EditNoteTitleInput!) { + editNoteTitle(input: $input) { + ...NoteFields + } + } +`);