Skip to content

Commit

Permalink
feat(notes): update document content (#989)
Browse files Browse the repository at this point in the history
  • Loading branch information
kschelonka authored Dec 4, 2024
1 parent 7d37623 commit 076aa3e
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 9 deletions.
25 changes: 24 additions & 1 deletion servers/notes-api/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
"""
Expand All @@ -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"]])
}
32 changes: 31 additions & 1 deletion servers/notes-api/src/__generated__/graphql.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions servers/notes-api/src/apollo/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
},
};
49 changes: 42 additions & 7 deletions servers/notes-api/src/datasources/NoteService.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<DB, 'Note', 'Note', UpdateResult> {
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,
Expand All @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions servers/notes-api/src/models/Note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
}
}
113 changes: 113 additions & 0 deletions servers/notes-api/src/test/mutations/editNoteContent.integration.ts
Original file line number Diff line number Diff line change
@@ -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<IContext>;
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',
);
});
});
9 changes: 9 additions & 0 deletions servers/notes-api/src/test/operations/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
`);

0 comments on commit 076aa3e

Please sign in to comment.