diff --git a/servers/notes-api/schema.graphql b/servers/notes-api/schema.graphql index 08ef7be9a..16927e9bf 100644 --- a/servers/notes-api/schema.graphql +++ b/servers/notes-api/schema.graphql @@ -159,6 +159,16 @@ input EditNoteContentInput { updatedAt: ISOString } +input DeleteNoteInput { + """The ID of the note to delete""" + id: ID! + """ + When the note was deleted was made. If not provided, defaults to + the server time upon receiving request. + """ + deletedAt: ISOString +} + type Mutation { """ Create a new note, optionally with title and content @@ -183,4 +193,10 @@ type Mutation { errors array. """ editNoteContent(input: EditNoteContentInput!): Note @requiresScopes(scopes: [["ROLE_USER"]]) + """ + Delete a note and all attachments. Returns True if the note was successfully + deleted. If the note cannot be deleted or does not exist, returns False. + Errors will be included in the errors array if applicable. + """ + deleteNote(input: DeleteNoteInput!): ID! @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 da72b76dd..e4e712094 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; }; +export type DeleteNoteInput = { + /** + * When the note was deleted was made. If not provided, defaults to + * the server time upon receiving request. + */ + deletedAt?: InputMaybe; + /** The ID of the note to delete */ + id: Scalars['ID']['input']; +}; + /** Input for editing the content of a Note (user-generated) */ export type EditNoteContentInput = { /** JSON representation of a ProseMirror document */ @@ -109,6 +119,12 @@ export type Mutation = { * selected by a user. */ createNoteFromQuote: Note; + /** + * Delete a note and all attachments. Returns True if the note was successfully + * deleted. If the note cannot be deleted or does not exist, returns False. + * Errors will be included in the errors array if applicable. + */ + deleteNote: Scalars['ID']['output']; /** * Edit the content of a Note. * If the Note does not exist or is inaccessible for the current user, @@ -136,6 +152,11 @@ export type MutationCreateNoteFromQuoteArgs = { }; +export type MutationDeleteNoteArgs = { + input: DeleteNoteInput; +}; + + export type MutationEditNoteContentArgs = { input: EditNoteContentInput; }; @@ -288,6 +309,7 @@ export type ResolversTypes = ResolversObject<{ ID: ResolverTypeWrapper; String: ResolverTypeWrapper; CreateNoteInput: CreateNoteInput; + DeleteNoteInput: DeleteNoteInput; EditNoteContentInput: EditNoteContentInput; EditNoteTitleInput: EditNoteTitleInput; ISOString: ResolverTypeWrapper; @@ -307,6 +329,7 @@ export type ResolversParentTypes = ResolversObject<{ ID: Scalars['ID']['output']; String: Scalars['String']['output']; CreateNoteInput: CreateNoteInput; + DeleteNoteInput: DeleteNoteInput; EditNoteContentInput: EditNoteContentInput; EditNoteTitleInput: EditNoteTitleInput; ISOString: Scalars['ISOString']['output']; @@ -331,6 +354,7 @@ export interface MarkdownScalarConfig extends GraphQLScalarTypeConfig = ResolversObject<{ createNote?: Resolver>; createNoteFromQuote?: Resolver>; + deleteNote?: 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 3343ddb3a..12640a4a5 100644 --- a/servers/notes-api/src/apollo/resolvers.ts +++ b/servers/notes-api/src/apollo/resolvers.ts @@ -21,5 +21,8 @@ export const resolvers: Resolvers = { editNoteContent(root, { input }, context) { return context.NoteModel.editContent(input); }, + deleteNote(root, { input }, context) { + return context.NoteModel.deleteNote(input); + }, }, }; diff --git a/servers/notes-api/src/datasources/NoteService.ts b/servers/notes-api/src/datasources/NoteService.ts index 860ba59b7..6df3acb86 100644 --- a/servers/notes-api/src/datasources/NoteService.ts +++ b/servers/notes-api/src/datasources/NoteService.ts @@ -62,6 +62,7 @@ export class NotesService { .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 @@ -109,4 +110,22 @@ 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 delete(noteId: string, deletedAt?: Date | string | null) { + const setUpdate = + deletedAt != null + ? { deleted: true, updatedAt: deletedAt } + : { deleted: true, updatedAt: new Date(Date.now()) }; + const result = await this.updateBase(noteId) + .set(setUpdate) + .returning('noteId') + .executeTakeFirstOrThrow(); + return result.noteId; + } } diff --git a/servers/notes-api/src/models/Note.ts b/servers/notes-api/src/models/Note.ts index 60ea4897c..29627ae69 100644 --- a/servers/notes-api/src/models/Note.ts +++ b/servers/notes-api/src/models/Note.ts @@ -5,6 +5,7 @@ import { CreateNoteFromQuoteInput, EditNoteTitleInput, EditNoteContentInput, + DeleteNoteInput, } from '../__generated__/graphql'; import { Note as NoteEntity } from '../__generated__/db'; import { Insertable, NoResultError, Selectable } from 'kysely'; @@ -202,4 +203,18 @@ export class NoteModel { } } } + /** + * Delete a Note + */ + async deleteNote(input: DeleteNoteInput) { + try { + return await this.service.delete(input.id, input.deletedAt); + } catch (error) { + if (error instanceof NoResultError) { + return input.id; + } else { + throw error; + } + } + } } diff --git a/servers/notes-api/src/test/mutations/deleteNote.integration.ts b/servers/notes-api/src/test/mutations/deleteNote.integration.ts new file mode 100644 index 000000000..2002234d0 --- /dev/null +++ b/servers/notes-api/src/test/mutations/deleteNote.integration.ts @@ -0,0 +1,91 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { DELETE_NOTE, GET_NOTE } from '../operations'; +import { db, roDb } from '../../datasources/db'; +import { sql } from 'kysely'; +import { Chance } from 'chance'; +import { Note as NoteFaker } from '../fakes/Note'; +import { DeleteNoteInput } from '../../__generated__/graphql'; + +let app: Application; +let server: ApolloServer; +let graphQLUrl: string; +const chance = new Chance(); +const notes = [...Array(4).keys()].map((_) => NoteFaker(chance)); + +beforeAll(async () => { + await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); + // 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(); + await roDb.destroy(); +}); + +describe('note', () => { + it('deletes a note with a timestamp', async () => { + const now = new Date(Date.now()); + const { userId, noteId } = notes[0]; + const input: DeleteNoteInput = { + id: noteId, + deletedAt: now.toISOString(), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: DELETE_NOTE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.deleteNote).toEqual(noteId); + // Do a roundtrip and query back the note + const noteRoundtrip = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: GET_NOTE, variables: { id: noteId } }); + const note = noteRoundtrip.body.data?.note; + expect(note.deleted).toBeTrue(); + expect(note.updatedAt).toEqual(now.toISOString()); + }); + it('deletes a note without a timestamp', async () => { + const now = new Date(Date.now()); + const { userId, noteId } = notes[1]; + const input: DeleteNoteInput = { + id: noteId, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: DELETE_NOTE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.deleteNote).toEqual(noteId); + // Do a roundtrip and query back the Note + const noteRoundtrip = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: GET_NOTE, variables: { id: noteId } }); + const note = noteRoundtrip.body.data?.note; + expect(note.deleted).toBeTrue(); + const updatedAt = new Date(note.updatedAt); + expect(updatedAt.getTime() - now.getTime()).toBeLessThanOrEqual(10000); // within 10 seconds of when this test started + }); + it('Returns ID for nonexistent note (no errors)', async () => { + const input: DeleteNoteInput = { + id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: DELETE_NOTE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.deleteNote).toEqual(input.id); + }); +}); diff --git a/servers/notes-api/src/test/operations/mutations.ts b/servers/notes-api/src/test/operations/mutations.ts index a3a96e09c..23d367c1c 100644 --- a/servers/notes-api/src/test/operations/mutations.ts +++ b/servers/notes-api/src/test/operations/mutations.ts @@ -53,3 +53,9 @@ export const EDIT_NOTE_CONTENT = print(gql` } } `); + +export const DELETE_NOTE = print(gql` + mutation DeleteNote($input: DeleteNoteInput!) { + deleteNote(input: $input) + } +`); diff --git a/servers/notes-api/src/test/queries/note.integration.ts b/servers/notes-api/src/test/queries/note.integration.ts index 74ad2e682..9e4a4e429 100644 --- a/servers/notes-api/src/test/queries/note.integration.ts +++ b/servers/notes-api/src/test/queries/note.integration.ts @@ -3,7 +3,7 @@ import request from 'supertest'; import { IContext, startServer } from '../../apollo'; import { type Application } from 'express'; import { GET_NOTE } from '../operations'; -import { db } from '../../datasources/db'; +import { db, roDb } from '../../datasources/db'; import { Note as NoteFaker } from '../fakes/Note'; import { Chance } from 'chance'; import { sql } from 'kysely'; @@ -26,6 +26,7 @@ afterAll(async () => { await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); await server.stop(); await db.destroy(); + await roDb.destroy(); }); describe('note', () => {