From 9118d8c6da0fc218fe58514f595ac093f1c4b98b Mon Sep 17 00:00:00 2001 From: Kat Schelonka <34227334+kschelonka@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:47:12 -0800 Subject: [PATCH] feat(note): create note mutation (#980) * feat(notes): createNote mutation [POCKET-10860] * fix(note): make prosemirror doc a unique scalar Enables custom serialization/deserialization/parsing etc. in the future. --- .../src/scalars/ValidUrlScalar.ts | 2 +- .../src/scalars/isoStringScalar.ts | 7 + servers/notes-api/codegen.ts | 8 ++ .../migration.sql | 2 + servers/notes-api/prisma/schema.prisma | 2 +- servers/notes-api/schema.graphql | 38 +++++- servers/notes-api/src/__generated__/db.d.ts | 2 +- .../notes-api/src/__generated__/graphql.d.ts | 54 +++++++- servers/notes-api/src/apollo/context.ts | 5 +- servers/notes-api/src/apollo/resolvers.ts | 5 + .../notes-api/src/datasources/NoteService.ts | 13 ++ servers/notes-api/src/models/Note.ts | 46 ++++++- .../test/mutations/createNote.integration.ts | 125 ++++++++++++++++++ .../notes-api/src/test/operations/index.ts | 1 + .../src/test/operations/mutations.ts | 28 ++++ 15 files changed, 323 insertions(+), 15 deletions(-) create mode 100644 servers/notes-api/prisma/migrations/20241126231315_note_updated_generated/migration.sql create mode 100644 servers/notes-api/src/test/mutations/createNote.integration.ts create mode 100644 servers/notes-api/src/test/operations/mutations.ts diff --git a/packages/apollo-utils/src/scalars/ValidUrlScalar.ts b/packages/apollo-utils/src/scalars/ValidUrlScalar.ts index d5891e025..0576c42db 100644 --- a/packages/apollo-utils/src/scalars/ValidUrlScalar.ts +++ b/packages/apollo-utils/src/scalars/ValidUrlScalar.ts @@ -36,7 +36,7 @@ export const GraphQLValidUrl = /*#__PURE__*/ new GraphQLScalarType({ } }, extensions: { - codegenScalarType: 'ValidUrl | string', + codegenScalarType: 'URL | string', jsonSchema: { type: 'string', format: 'uri', diff --git a/packages/apollo-utils/src/scalars/isoStringScalar.ts b/packages/apollo-utils/src/scalars/isoStringScalar.ts index 6a271668a..6dd369545 100644 --- a/packages/apollo-utils/src/scalars/isoStringScalar.ts +++ b/packages/apollo-utils/src/scalars/isoStringScalar.ts @@ -86,4 +86,11 @@ export const isoStringScalar = new GraphQLScalarType({ return this.parseValue(ast.value); }, + extensions: { + codegenScalarType: 'Date | string', + jsonSchema: { + type: 'string', + format: 'date-time', + }, + }, }); diff --git a/servers/notes-api/codegen.ts b/servers/notes-api/codegen.ts index 3b31bf214..49b588ab8 100644 --- a/servers/notes-api/codegen.ts +++ b/servers/notes-api/codegen.ts @@ -1,4 +1,5 @@ import { CodegenConfig } from '@graphql-codegen/cli'; +import { PocketDefaultScalars } from '@pocket-tools/apollo-utils'; const config: CodegenConfig = { schema: './schema.graphql', @@ -8,6 +9,13 @@ const config: CodegenConfig = { federation: true, useIndexSignature: true, contextType: '../apollo/context#IContext', + scalars: { + ValidUrl: PocketDefaultScalars.ValidUrl.extensions.codegenScalarType, + ISOString: + PocketDefaultScalars.ISOString.extensions.codegenScalarType, + Markdown: 'string', + ProseMirrorJson: 'string', + }, }, plugins: [ //generated types do not conform to ts/lint rules, disable them for these files diff --git a/servers/notes-api/prisma/migrations/20241126231315_note_updated_generated/migration.sql b/servers/notes-api/prisma/migrations/20241126231315_note_updated_generated/migration.sql new file mode 100644 index 000000000..40d1a81e7 --- /dev/null +++ b/servers/notes-api/prisma/migrations/20241126231315_note_updated_generated/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Note" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP(3); diff --git a/servers/notes-api/prisma/schema.prisma b/servers/notes-api/prisma/schema.prisma index f1e939b3f..a9a0124a7 100644 --- a/servers/notes-api/prisma/schema.prisma +++ b/servers/notes-api/prisma/schema.prisma @@ -27,7 +27,7 @@ model Note { title String? @db.VarChar(300) sourceUrl String? createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP(0)")) @db.Timestamptz(0) - updatedAt DateTime @db.Timestamptz(3) + updatedAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP(3)")) @db.Timestamptz(3) docContent Json? deleted Boolean @default(false) archived Boolean @default(false) diff --git a/servers/notes-api/schema.graphql b/servers/notes-api/schema.graphql index 2dcd0e58d..9155871b8 100644 --- a/servers/notes-api/schema.graphql +++ b/servers/notes-api/schema.graphql @@ -6,6 +6,7 @@ extend schema scalar ISOString scalar ValidUrl scalar Markdown +scalar ProseMirrorJson """ A Note is an entity which may contain extracted components @@ -24,7 +25,7 @@ type Note @key(fields: "id") { """ JSON representation of a ProseMirror document """ - docContent: String + docContent: ProseMirrorJson """ Markdown preview of the note content for summary view. """ @@ -68,3 +69,38 @@ type Query { """Retrieve a specific Note""" note(id: ID!): Note @requiresScopes(scopes: [["ROLE_USER"]]) } + + +""" +Input to create a new Note +""" +input CreateNoteInput { + """Optional title for this Note""" + title: String + """ + Client-provided UUID for the new Note. + If not provided, will be generated on the server. + """ + id: ID + """ + Optional URL to link this Note to. + """ + source: ValidUrl + """ + JSON representation of a ProseMirror document + """ + docContent: ProseMirrorJson! + """ + When this note was created. If not provided, defaults to server time upon + receiving request. + """ + createdAt: ISOString +} + + +type Mutation { + """ + Create a new note, optionally with title and content + """ + createNote(input: CreateNoteInput!): Note! @requiresScopes(scopes: [["ROLE_USER"]]) +} diff --git a/servers/notes-api/src/__generated__/db.d.ts b/servers/notes-api/src/__generated__/db.d.ts index 0dd9243e6..914077ebd 100644 --- a/servers/notes-api/src/__generated__/db.d.ts +++ b/servers/notes-api/src/__generated__/db.d.ts @@ -55,7 +55,7 @@ export interface Note { noteId: Generated; sourceUrl: string | null; title: string | null; - updatedAt: Timestamp; + updatedAt: Generated; userId: string; } diff --git a/servers/notes-api/src/__generated__/graphql.d.ts b/servers/notes-api/src/__generated__/graphql.d.ts index 6795daf4f..d3a06271b 100644 --- a/servers/notes-api/src/__generated__/graphql.d.ts +++ b/servers/notes-api/src/__generated__/graphql.d.ts @@ -18,12 +18,43 @@ export type Scalars = { Boolean: { input: boolean; output: boolean; } Int: { input: number; output: number; } Float: { input: number; output: number; } - ISOString: { input: any; output: any; } - Markdown: { input: any; output: any; } - ValidUrl: { input: any; output: any; } + ISOString: { input: Date | string; output: Date | string; } + Markdown: { input: string; output: string; } + ValidUrl: { input: URL | string; output: URL | string; } _FieldSet: { input: any; output: any; } }; +/** Input to create a new Note */ +export type CreateNoteInput = { + /** + * When this note was created. If not provided, defaults to server time upon + * receiving request. + */ + createdAt?: InputMaybe; + /** JSON representation of a ProseMirror document */ + docContent: Scalars['String']['input']; + /** + * Client-provided UUID for the new Note. + * If not provided, will be generated on the server. + */ + id?: InputMaybe; + /** Optional URL to link this Note to. */ + source?: InputMaybe; + /** Optional title for this Note */ + title?: InputMaybe; +}; + +export type Mutation = { + __typename?: 'Mutation'; + /** Create a new note, optionally with title and content */ + createNote: Note; +}; + + +export type MutationCreateNoteArgs = { + input: CreateNoteInput; +}; + /** * A Note is an entity which may contain extracted components * from websites (clippings/snippets), user-generated rich text content, @@ -163,12 +194,14 @@ export type DirectiveResolverFn; + ID: ResolverTypeWrapper; ISOString: ResolverTypeWrapper; Markdown: ResolverTypeWrapper; + Mutation: ResolverTypeWrapper<{}>; Note: ResolverTypeWrapper; Boolean: ResolverTypeWrapper; - String: ResolverTypeWrapper; - ID: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; SavedItem: ResolverTypeWrapper; ValidUrl: ResolverTypeWrapper; @@ -176,12 +209,14 @@ export type ResolversTypes = ResolversObject<{ /** Mapping between all available schema types and the resolvers parents */ export type ResolversParentTypes = ResolversObject<{ + CreateNoteInput: CreateNoteInput; + String: Scalars['String']['output']; + ID: Scalars['ID']['output']; ISOString: Scalars['ISOString']['output']; Markdown: Scalars['Markdown']['output']; + Mutation: {}; Note: Note; Boolean: Scalars['Boolean']['output']; - String: Scalars['String']['output']; - ID: Scalars['ID']['output']; Query: {}; SavedItem: SavedItem; ValidUrl: Scalars['ValidUrl']['output']; @@ -195,6 +230,10 @@ export interface MarkdownScalarConfig extends GraphQLScalarTypeConfig = ResolversObject<{ + createNote?: Resolver>; +}>; + export type NoteResolvers = ResolversObject<{ __resolveReference?: ReferenceResolver, { __typename: 'Note' } & GraphQLRecursivePick, ContextType>; archived?: Resolver; @@ -227,6 +266,7 @@ export interface ValidUrlScalarConfig extends GraphQLScalarTypeConfig = ResolversObject<{ ISOString?: GraphQLScalarType; Markdown?: GraphQLScalarType; + Mutation?: MutationResolvers; Note?: NoteResolvers; Query?: QueryResolvers; SavedItem?: SavedItemResolvers; diff --git a/servers/notes-api/src/apollo/context.ts b/servers/notes-api/src/apollo/context.ts index c2c0aabfc..0fdf92c67 100644 --- a/servers/notes-api/src/apollo/context.ts +++ b/servers/notes-api/src/apollo/context.ts @@ -7,7 +7,7 @@ import { } from '@pocket-tools/apollo-utils'; import { Kysely } from 'kysely'; import { DB } from '../__generated__/db'; -import { roDb } from '../datasources/db'; +import { roDb, db } from '../datasources/db'; import { NoteModel } from '../models/Note'; /** @@ -28,12 +28,14 @@ export async function getContext({ export interface IContext extends PocketContext { roDb: Kysely; + db: Kysely; userId: string; NoteModel: NoteModel; } export class ContextManager extends PocketContextManager implements IContext { roDb: Kysely; + db: Kysely; _userId: string; NoteModel: NoteModel; constructor(options: { request: Request }) { @@ -48,6 +50,7 @@ export class ContextManager extends PocketContextManager implements IContext { this._userId = super.userId; } this.roDb = roDb; + this.db = db; this.NoteModel = new NoteModel(this); } /** Override because userId is guaranteed in this Context */ diff --git a/servers/notes-api/src/apollo/resolvers.ts b/servers/notes-api/src/apollo/resolvers.ts index ba853b447..9fb137886 100644 --- a/servers/notes-api/src/apollo/resolvers.ts +++ b/servers/notes-api/src/apollo/resolvers.ts @@ -8,4 +8,9 @@ export const resolvers: Resolvers = { return context.NoteModel.load(id); }, }, + Mutation: { + createNote(root, { input }, context) { + return context.NoteModel.create(input); + }, + }, }; diff --git a/servers/notes-api/src/datasources/NoteService.ts b/servers/notes-api/src/datasources/NoteService.ts index dc435f89a..13ba33b32 100644 --- a/servers/notes-api/src/datasources/NoteService.ts +++ b/servers/notes-api/src/datasources/NoteService.ts @@ -1,4 +1,6 @@ +import { Insertable } from 'kysely'; import { IContext } from '../apollo/context'; +import { Note as NoteEntity } from '../__generated__/db'; /** * Database methods for retrieving and creating Notes @@ -36,4 +38,15 @@ export class NotesService { .execute(); return result; } + /** + * Create a new Note and return the row. + */ + async create(entity: Insertable) { + const result = await this.context.db + .insertInto('Note') + .values(entity) + .returningAll() + .executeTakeFirstOrThrow(); + return result; + } } diff --git a/servers/notes-api/src/models/Note.ts b/servers/notes-api/src/models/Note.ts index 7643d31d4..d9ab9991a 100644 --- a/servers/notes-api/src/models/Note.ts +++ b/servers/notes-api/src/models/Note.ts @@ -1,11 +1,13 @@ import DataLoader from 'dataloader'; -import { Note } from '../__generated__/graphql'; +import { Note, CreateNoteInput } from '../__generated__/graphql'; import { Note as NoteEntity } from '../__generated__/db'; -import { Selectable } from 'kysely'; +import { Insertable, Selectable } from 'kysely'; import { orderAndMap } from '../utils/dataloader'; import { IContext } from '../apollo/context'; import { NotesService } from '../datasources/NoteService'; import { ProseMirrorDoc } from './ProseMirrorDoc'; +import { UserInputError } from '@pocket-tools/apollo-utils'; +import { DatabaseError } from 'pg'; /** * Model for retrieving and creating Notes @@ -13,7 +15,7 @@ import { ProseMirrorDoc } from './ProseMirrorDoc'; export class NoteModel { loader: DataLoader | null>; service: NotesService; - constructor(context: IContext) { + constructor(public readonly context: IContext) { this.service = new NotesService(context); this.loader = new DataLoader | null>( async (keys: readonly string[]) => { @@ -77,4 +79,42 @@ export class NoteModel { const note = await this.loader.load(id); return note != null ? this.toGraphql(note) : null; } + /** + * Create a new Note. + */ + async create(input: CreateNoteInput) { + try { + // At some point do more validation + // We can move this to a scalar + const docContent = JSON.parse(input.docContent); + const entity: Insertable = { + createdAt: input.createdAt ?? undefined, + docContent, + noteId: input.id ?? undefined, + sourceUrl: input.source?.toString() ?? undefined, + title: input.title ?? undefined, + userId: this.context.userId, + updatedAt: input.createdAt ?? undefined, + }; + const note = await this.service.create(entity); + return this.toGraphql(note); + } catch (error) { + if (error instanceof SyntaxError) { + throw new UserInputError( + `Received malformed JSON for docContent: ${error.message}`, + ); + } else if (error instanceof DatabaseError) { + if (error.code === '23505' && error.constraint === 'Note_noteId_key') { + throw new UserInputError( + `Received duplicate value for note ID. ` + + `Ensure you are generating v4 UUIDs and try again.`, + ); + } else { + throw error; + } + } else { + throw error; + } + } + } } diff --git a/servers/notes-api/src/test/mutations/createNote.integration.ts b/servers/notes-api/src/test/mutations/createNote.integration.ts new file mode 100644 index 000000000..58088f090 --- /dev/null +++ b/servers/notes-api/src/test/mutations/createNote.integration.ts @@ -0,0 +1,125 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { CREATE_NOTE } from '../operations'; +import { db } from '../../datasources/db'; +import { sql } from 'kysely'; +import { CreateNoteInput } from '../../__generated__/graphql'; +import basicText from '../documents/basicText.json'; +import { Chance } from 'chance'; + +let app: Application; +let server: ApolloServer; +let graphQLUrl: string; + +beforeAll(async () => { + // port 0 tells express to dynamically assign an available port + ({ app, server, url: graphQLUrl } = await startServer(0)); +}); +afterAll(async () => { + await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); + await server.stop(); + await db.destroy(); +}); + +describe('note', () => { + it('creates a note with minimal inputs', async () => { + const input: CreateNoteInput = { + docContent: JSON.stringify(basicText), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.createNote).toMatchObject({ + archived: false, + contentPreview: expect.toBeString(), + createdAt: expect.toBeDateString(), + deleted: false, + id: expect.toBeString(), + savedItem: null, + source: null, + title: null, + updatedAt: expect.toBeDateString(), + }); + // The keys get reordered so we have to deeply compare the + // JSON-serialized results + const receivedDoc = res.body.data?.createNote?.docContent; + expect(receivedDoc).not.toBeNil(); + expect(JSON.parse(receivedDoc)).toStrictEqual(basicText); + }); + it('creates a note with optional fields', async () => { + const chance = new Chance(); + const createdAt = new Date(chance.hammertime()); + const input: CreateNoteInput = { + title: chance.sentence(), + createdAt, + source: chance.url(), + id: chance.guid({ version: 4 }), + docContent: JSON.stringify(basicText), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.createNote).toMatchObject({ + archived: false, + contentPreview: expect.toBeString(), + createdAt: new Date( + Math.round(createdAt.getTime() / 1000) * 1000, + ).toISOString(), + deleted: false, + id: input.id, + savedItem: { + url: input.source, + }, + source: input.source, + title: input.title, + updatedAt: createdAt.toISOString(), + }); + }); + it('throws error for duplicate UUID', async () => { + const uuid = 'ccab26fb-64a5-4071-9044-f42bc2470884'; + const input: CreateNoteInput = { + docContent: JSON.stringify(basicText), + }; + const seed = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ + query: CREATE_NOTE, + variables: { input: { ...input, id: uuid } }, + }); + expect(seed.body.errors).toBeNil(); + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ + query: CREATE_NOTE, + variables: { input: { ...input, id: uuid } }, + }); + 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 duplicate value for note ID', + ); + }); + it('throws error for bad JSON', async () => { + const input: CreateNoteInput = { + docContent: "{ 'bad': 'json'", + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE, 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/index.ts b/servers/notes-api/src/test/operations/index.ts index 3cf1ef310..d0720956a 100644 --- a/servers/notes-api/src/test/operations/index.ts +++ b/servers/notes-api/src/test/operations/index.ts @@ -1 +1,2 @@ export * from './queries'; +export * from './mutations'; diff --git a/servers/notes-api/src/test/operations/mutations.ts b/servers/notes-api/src/test/operations/mutations.ts new file mode 100644 index 000000000..9ab35d815 --- /dev/null +++ b/servers/notes-api/src/test/operations/mutations.ts @@ -0,0 +1,28 @@ +import { gql } from 'graphql-tag'; +import { print } from 'graphql'; + +const NoteFragment = gql` + fragment NoteFields on Note { + id + title + docContent + contentPreview + createdAt + updatedAt + savedItem { + url + } + source + archived + deleted + } +`; + +export const CREATE_NOTE = print(gql` + ${NoteFragment} + mutation CreateNote($input: CreateNoteInput!) { + createNote(input: $input) { + ...NoteFields + } + } +`);