diff --git a/src/adverts/advert-mutations/cancel-advert-reservation.ts b/src/adverts/advert-mutations/cancel-advert-reservation.ts index a6e6d93..a152ecf 100644 --- a/src/adverts/advert-mutations/cancel-advert-reservation.ts +++ b/src/adverts/advert-mutations/cancel-advert-reservation.ts @@ -9,7 +9,7 @@ const countReservationsByUser = (user: HaffaUser, reservations: AdvertReservatio export const createCancelAdvertReservation = ({ adverts, notifications }: Pick): AdvertMutations['cancelAdvertReservation'] => (user, id) => txBuilder() - .load(() => adverts.getAdvert(id)) + .load(() => adverts.getAdvert(user, id)) .validate(() => undefined) .patch((advert, {actions}) => { actions((patched, original) => notifications.advertReservationWasCancelled( @@ -22,7 +22,7 @@ export const createCancelAdvertReservation = ({ adverts, notifications }: Pick update) - .saveVersion( (versionId, advert) => adverts.saveAdvertVersion(versionId, advert)) + .saveVersion( (versionId, advert) => adverts.saveAdvertVersion(user, versionId, advert)) .run() .then(mapTxResultToAdvertMutationResult) diff --git a/src/adverts/advert-mutations/remove-advert.ts b/src/adverts/advert-mutations/remove-advert.ts index e986156..6b579af 100644 --- a/src/adverts/advert-mutations/remove-advert.ts +++ b/src/adverts/advert-mutations/remove-advert.ts @@ -6,11 +6,11 @@ import { mapTxResultToAdvertMutationResult } from './mappers' export const createRemoveAdvert = ({ adverts }: Pick): AdvertMutations['removeAdvert'] => async (user, id) => txBuilder() - .load(() => adverts.getAdvert(id)) + .load(() => adverts.getAdvert(user, id)) .validate(async (advert, {throwIf}) => throwIf(!getAdvertMeta(advert, user).canRemove, TxErrors.Unauthorized)) .patch(async (data) => data) .verify(async (update) => update) - .saveVersion(async () => adverts.remove(id)) + .saveVersion(async () => adverts.remove(user, id)) .run() .then(mapTxResultToAdvertMutationResult) diff --git a/src/adverts/advert-mutations/reserve-advert.ts b/src/adverts/advert-mutations/reserve-advert.ts index 64017ac..f323d4b 100644 --- a/src/adverts/advert-mutations/reserve-advert.ts +++ b/src/adverts/advert-mutations/reserve-advert.ts @@ -6,7 +6,7 @@ import { verifyAll, verifyReservationLimits, verifyTypeIsReservation } from './v export const createReserveAdvert = ({ adverts, notifications }: Pick): AdvertMutations['reserveAdvert'] => (user, id, quantity) => txBuilder() - .load(() => adverts.getAdvert(id)) + .load(() => adverts.getAdvert(user, id)) .validate(() => {}) .patch((advert, {actions}) => { if (quantity > 0) { @@ -27,6 +27,6 @@ export const createReserveAdvert = ({ adverts, notifications }: Pick adverts.saveAdvertVersion(versionId, advert)) + .saveVersion( (versionId, advert) => adverts.saveAdvertVersion(user, versionId, advert)) .run() .then(mapTxResultToAdvertMutationResult) \ No newline at end of file diff --git a/src/adverts/advert-mutations/update-advert.ts b/src/adverts/advert-mutations/update-advert.ts index 034cf1f..ad67b27 100644 --- a/src/adverts/advert-mutations/update-advert.ts +++ b/src/adverts/advert-mutations/update-advert.ts @@ -9,13 +9,13 @@ import { verifyAll, verifyQuantityAtleatOne, verifyReservationsDoesNotExceedQuan export const createUpdateAdvert = ({ adverts, files }: Pick): AdvertMutations['updateAdvert'] => (user, id, input) => txBuilder() - .load(() => adverts.getAdvert(id)) + .load(() => adverts.getAdvert(user, id)) .validate((advert, {throwIf}) => throwIf(!getAdvertMeta(advert, user).canEdit, TxErrors.Unauthorized)) .patch(async (advert) => ({ ...advert, ...await processAdvertInput(input, files), })) .verify((_, ctx) => verifyAll(ctx, verifyQuantityAtleatOne, verifyReservationsDoesNotExceedQuantity)) - .saveVersion((versionId, advert) => adverts.saveAdvertVersion(versionId, advert)) + .saveVersion((versionId, advert) => adverts.saveAdvertVersion(user, versionId, advert)) .run() .then(mapTxResultToAdvertMutationResult) diff --git a/src/adverts/adverts-gql-module.ts b/src/adverts/adverts-gql-module.ts index 22a7398..9ec958b 100644 --- a/src/adverts/adverts-gql-module.ts +++ b/src/adverts/adverts-gql-module.ts @@ -10,11 +10,11 @@ export const createAdvertsGqlModule = (services: Pick { - const l = await services.adverts.list(filter) + const l = await services.adverts.list(user, filter) return mapAdvertsToAdvertsWithMeta(user, l) }, getAdvert: async ({ ctx: { user }, args: { id } }) => { - const advert = await services.adverts.getAdvert(id) + const advert = await services.adverts.getAdvert(user, id) return mapAdvertToAdvertWithMeta(user, advert) }, }, diff --git a/src/adverts/adverts.gql.schema.ts b/src/adverts/adverts.gql.schema.ts index 7b21945..5172a0a 100644 --- a/src/adverts/adverts.gql.schema.ts +++ b/src/adverts/adverts.gql.schema.ts @@ -1,7 +1,7 @@ export const advertsGqlSchema = /* GraphQL */` type Query { - adverts(filter: FilterAdvertsInput): [Advert] + adverts(filter: AdvertFilterInput): [Advert] getAdvert(id: ID!): Advert } @@ -34,7 +34,13 @@ input StringFilterInput { contains: String } -input FilterAdvertsInput { +enum AdvertSortableFieldEnum { + id + title + createdAt +} + +input AdvertFieldsFilterInput { id: StringFilterInput title: StringFilterInput description: StringFilterInput @@ -43,9 +49,27 @@ input FilterAdvertsInput { condition: StringFilterInput usage: StringFilterInput - and: [FilterAdvertsInput] - or: [FilterAdvertsInput] - not: FilterAdvertsInput + and: [AdvertFieldsFilterInput] + or: [AdvertFieldsFilterInput] + not: AdvertFieldsFilterInput +} + +input AdvertRestrictionsInput { + canBeReserved: Boolean + reservedByMe: Boolean + createdByMe: Boolean +} + +input AdvertSortingInput { + field: AdvertSortableFieldEnum + ascending: Boolean +} + +input AdvertFilterInput { + search: String + field: AdvertFieldsFilterInput + restrictions: AdvertRestrictionsInput + sorting: AdvertSortingInput } input AdvertLocationInput { diff --git a/src/adverts/filters/advert-filter-predicate.spec.ts b/src/adverts/filters/advert-filter-predicate.spec.ts new file mode 100644 index 0000000..4dab4f3 --- /dev/null +++ b/src/adverts/filters/advert-filter-predicate.spec.ts @@ -0,0 +1,136 @@ +import {createAdvertFilterPredicate} from './advert-filter-predicate' +import {createEmptyAdvert} from './../mappers' +import { Advert } from '../types' +import { HaffaUser } from '../../login/types' +describe('createAdvertFilterPredicate', () => { + + const createTestUser = (user?: Partial): HaffaUser => ({ + id: 'test@testerson.com', + roles: [], + ...user + }) + + const createSampleAdverts = ( + count: number, + patches?: Record> + ): Advert[] => [...Array(count)].map(createEmptyAdvert) + .map((advert, index) => ({...advert, id: `advert-${index}`})) + .map(advert => ({ + ...advert, + ...patches?.[advert.id] + })) + + it('matches all by default', () => { + const adverts = createSampleAdverts(10); + expect(adverts.filter(createAdvertFilterPredicate(createTestUser()))) + .toMatchObject(adverts) + }) + + it('does free text search in {title, description}', () => { + const p = createAdvertFilterPredicate(createTestUser(), {search: 'unicorn'}) + + const adverts = createSampleAdverts(100, { + 'advert-10': {title: 'I like my unicorn!'}, + 'advert-20': {description: ' UniCorns are the best'} + }); + + expect( + adverts.filter(p) + .map(({id}) => id)) + .toMatchObject(['advert-10', 'advert-20']) + }) + + it('treats search text as a list of separate words combined with or to match {title, description}', () => { + const p = createAdvertFilterPredicate(createTestUser(), {search: 'unicorn orange banana'}) + + const adverts = createSampleAdverts(100, { + 'advert-10': {title: 'I like my unicorn!'}, + 'advert-20': {description: 'Oranges are the best'}, + 'advert-30': {description: 'my oranges brings unicorns to my yard'}, + 'advert-40': {description: 'the amazing banana bender'}}) + + expect( + adverts.filter(p) + .map(({id}) => id)) + .toMatchObject(['advert-10', 'advert-20', 'advert-30', 'advert-40']) + }) + + it('combines search text with filter predicates with logical AND', () => { + const p = createAdvertFilterPredicate(createTestUser(), {search: 'unicorn', fields: { + description: { + 'contains': 'orange' + } + }}) + + const adverts = createSampleAdverts(100, { + 'advert-10': {title: 'I like my unicorn!'}, + 'advert-20': {description: 'Unicorns are the best'}, + 'advert-30': {title: 'I have an unicorn', description: 'I loves oranges'}}) + + expect( + adverts.filter(p) + .map(({id}) => id)) + .toMatchObject(['advert-30']) + }) + + it('restricts to creator', () => { + const p = createAdvertFilterPredicate(createTestUser({id: 'a@b.com'}), {search: 'unicorn', restrictions: {createdByMe: true}}) + + const adverts = createSampleAdverts(100, { + 'advert-10': {title: 'I like my unicorn!'}, + 'advert-20': {description: ' UniCorns are the best'}, + 'advert-30': {description: ' My very own little unicorn', createdBy: 'a@b.com'} + }); + + expect( + adverts.filter(p) + .map(({id}) => id)) + .toMatchObject(['advert-30']) + }) + + it('restricts to reserved by', () => { + const p = createAdvertFilterPredicate(createTestUser({id: 'a@b.com'}), {search: 'unicorn', restrictions: {reservedByMe: true}}) + + const adverts = createSampleAdverts(100, { + 'advert-10': {title: 'I like my unicorn!'}, + 'advert-20': {description: ' UniCorns are the best'}, + 'advert-30': {description: ' My very own little unicorn', reservations: [{ + reservedBy: 'a@b.com', + reservedAt: new Date().toISOString(), + quantity: 1 + }]} + }); + + expect( + adverts.filter(p) + .map(({id}) => id)) + .toMatchObject(['advert-30']) + }) + + it('restricts to can be reserved', () => { + const p = createAdvertFilterPredicate(createTestUser({id: 'a@b.com'}), {search: 'unicorn', restrictions: {canBeReserved: true}}) + + let adverts = createSampleAdverts(100, { + 'advert-10': {title: 'I like my unicorn!'}, + 'advert-20': {description: ' UniCorns are the best'}, + 'advert-30': {description: ' My very own little unicorn'}}) + + // reserve all adverts except advert-30 + adverts = adverts.map(advert => + advert.id === 'advert-30' + ? advert + : ({ + ...advert, + reservations: [{ + reservedBy: 'someone@else', + reservedAt: new Date().toISOString(), + quantity: advert.quantity + }] + })) + + expect( + adverts.filter(p) + .map(({id}) => id)) + .toMatchObject(['advert-30']) + }) +}) \ No newline at end of file diff --git a/src/adverts/filters/advert-filter-predicate.ts b/src/adverts/filters/advert-filter-predicate.ts new file mode 100644 index 0000000..697c93f --- /dev/null +++ b/src/adverts/filters/advert-filter-predicate.ts @@ -0,0 +1,48 @@ +import type { HaffaUser } from "../../login/types" +import { getAdvertMeta } from "../advert-meta" +import type { Advert, AdvertFilterInput, AdvertRestrictionsFilterInput } from "../types" +import { createFieldFilterPredicate } from "./field-filter-predicate" +import type { Predicate } from "./types" + +const regexpEscape = (s: string): string => s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') + +const createFreeTextPredicate = (search: string): Predicate => { + // extract individual words from search + + const matchers = ((search||'').match(/(\w+)/g) || []) + .filter(v => v) + .filter(v => v.length >= 3) + .map(regexpEscape) + .map(re => new RegExp(re, 'ig')) + + return matchers.length > 0 + ? advert => matchers.some(re => re.test(advert.title) || re.test(advert.description)) + : () => true +} + +const createRestrictionsPredicate = (user: HaffaUser, restrictions: AdvertRestrictionsFilterInput): Predicate => { + const makeMatcher = (test: boolean|undefined, p: Predicate): Predicate|null => + // eslint-disable-next-line no-nested-ternary + test === true ? p : test === false ? advert => !p(advert) : null + + const matchers: (Predicate)[] = [ + makeMatcher(restrictions?.createdByMe, ({createdBy}) => createdBy === user.id), + makeMatcher(restrictions?.reservedByMe, ({reservations}) => reservations.some(({reservedBy}) => reservedBy === user.id)), + makeMatcher(restrictions?.canBeReserved, advert => getAdvertMeta(advert, user).canReserve) + ] + .filter(p => p) as Predicate[] + + return matchers.length > 0 + ? advert => matchers.every(m => m(advert)) + : () => true +} + +export const createAdvertFilterPredicate = (user: HaffaUser, input?: AdvertFilterInput): Predicate => { + const matchers = [ + createFreeTextPredicate(input?.search || ''), + createFieldFilterPredicate(input?.fields), + createRestrictionsPredicate(user, input?.restrictions || {}) + ] + // logical AND on all matchers + return (advert) => matchers.every(matcher => matcher(advert)) +} diff --git a/src/adverts/filters/advert-filter-sorter.ts b/src/adverts/filters/advert-filter-sorter.ts new file mode 100644 index 0000000..1250494 --- /dev/null +++ b/src/adverts/filters/advert-filter-sorter.ts @@ -0,0 +1,20 @@ +import type { HaffaUser } from "../../login/types"; +import type { Advert, AdvertFilterInput } from "../types"; +import type { Func2 } from "./types"; + +export const createAdvertFilterComparer = (user: HaffaUser, input?: AdvertFilterInput): Func2 => { + const field = input?.sorting?.field || 'createdAt' + const ascending = input?.sorting?.ascending !== false + + // NOTE: Array.toSorted() would be a better approach in the future + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted + + // eslint-disable-next-line no-nested-ternary + const asc = (a: any, b: any): number => (a === b) ? 0 : a < b ? -1 : 1 + // eslint-disable-next-line no-nested-ternary + const desc = (a: any, b: any): number => (a === b) ? 0 : a < b ? 1 : -1 + + return ascending + ? (a: Advert, b: Advert) => asc(a[field], b[field]) + : (a: Advert, b: Advert) => desc(a[field], b[field]) +} \ No newline at end of file diff --git a/src/adverts/filters/create-filter-predicate.spec.ts b/src/adverts/filters/field-filter-predicate.spec.ts similarity index 93% rename from src/adverts/filters/create-filter-predicate.spec.ts rename to src/adverts/filters/field-filter-predicate.spec.ts index eeaa09d..ead1d91 100644 --- a/src/adverts/filters/create-filter-predicate.spec.ts +++ b/src/adverts/filters/field-filter-predicate.spec.ts @@ -1,8 +1,8 @@ -import { createFilterPredicate } from './create-filter-predicate' +import { createFieldFilterPredicate } from "./field-filter-predicate" const range = (n: number): number[] => [...Array(n).keys()] -describe('createFilterPredicate', () => { +describe('createFieldFilterPredicate', () => { const data = range(100).map(i => ({ id: i, title: `title ${i}`, @@ -11,7 +11,7 @@ describe('createFilterPredicate', () => { })) const filteredData = (filter: any): any[] => - data.filter(createFilterPredicate(filter)) + data.filter(createFieldFilterPredicate(filter)) const testData = (filter: any, expected: any) => expect(filteredData(filter)).toMatchObject(expected) diff --git a/src/adverts/filters/create-filter-predicate.ts b/src/adverts/filters/field-filter-predicate.ts similarity index 89% rename from src/adverts/filters/create-filter-predicate.ts rename to src/adverts/filters/field-filter-predicate.ts index 7540f40..39b3a24 100644 --- a/src/adverts/filters/create-filter-predicate.ts +++ b/src/adverts/filters/field-filter-predicate.ts @@ -24,7 +24,7 @@ const createAndPredicate = (input: any): Predicate | null => { return null } const predicates = input - .map(inner => createFilterPredicate(inner)) + .map(inner => createFieldFilterPredicate(inner)) .filter(p => p) return value => predicates.every(p => p(value)) } @@ -33,7 +33,7 @@ const createOrPredicate = (input: any): Predicate | null => { if (!Array.isArray(input)) { return null } - const predicates = input.map(createFilterPredicate).filter(p => p) + const predicates = input.map(createFieldFilterPredicate).filter(p => p) return value => predicates.some(p => p(value)) } @@ -41,7 +41,7 @@ const createNotPredicate = (input: any): Predicate | null => { if (!isObject(input)) { return null } - const inner = createFilterPredicate(input) + const inner = createFieldFilterPredicate(input) return value => !inner(value) } @@ -53,7 +53,7 @@ const combinators: Record Predicate | null> = { const ifSomethingThenStuffItInAnArray = (v: T) => (v ? [v] : null) -export const createFilterPredicate = (input: any): Predicate => { +export const createFieldFilterPredicate = (input: any): Predicate => { if (!input) { return () => true } diff --git a/src/adverts/filters/types.ts b/src/adverts/filters/types.ts new file mode 100644 index 0000000..28cae08 --- /dev/null +++ b/src/adverts/filters/types.ts @@ -0,0 +1,11 @@ +export interface Predicate { + (value: T): boolean +} + +export interface Func1 { + (value: T): R +} + +export interface Func2 { + (a: T1, b: T2): R +} diff --git a/src/adverts/fs-adverts-repository/fs-adverts-repository.ts b/src/adverts/fs-adverts-repository/fs-adverts-repository.ts index fe506d0..0404855 100644 --- a/src/adverts/fs-adverts-repository/fs-adverts-repository.ts +++ b/src/adverts/fs-adverts-repository/fs-adverts-repository.ts @@ -2,18 +2,19 @@ import { join } from 'path' import { mkdirp } from 'mkdirp' import { readdir, readFile, stat, unlink, writeFile } from 'fs/promises' import type { AdvertsRepository } from '../types' -import { createFilterPredicate } from '../filters/create-filter-predicate' +import { createAdvertFilterPredicate } from '../filters/advert-filter-predicate' import { createEmptyAdvert, mapCreateAdvertInputToAdvert, patchAdvertWithAdvertInput } from '../mappers' +import { createAdvertFilterComparer } from '../filters/advert-filter-sorter' export const createFsAdvertsRepository = (dataFolder: string): AdvertsRepository => { - const getAdvert: AdvertsRepository['getAdvert'] = async (id: string) => readFile(join(dataFolder, `${id}.json`), { encoding: 'utf8' }) + const getAdvert: AdvertsRepository['getAdvert'] = async (user, id) => readFile(join(dataFolder, `${id}.json`), { encoding: 'utf8' }) .then(text => ({ ...createEmptyAdvert(), ...JSON.parse(text), })) .catch(() => null) - const list: AdvertsRepository['list'] = async (filter) => readdir(dataFolder) + const list: AdvertsRepository['list'] = async (user, filter) => readdir(dataFolder) .then(names => names.filter(name => /.*\.json$/.test(name))) .then(names => names.map(name => join(dataFolder, name))) .then(paths => Promise.all(paths.map(path => stat(path).then(s => ({ s, path }))))) @@ -23,7 +24,8 @@ export const createFsAdvertsRepository = (dataFolder: string): AdvertsRepository ...createEmptyAdvert(), ...JSON.parse(text), }))) - .then(adverts => adverts.filter(createFilterPredicate(filter))) + .then(adverts => adverts.filter(createAdvertFilterPredicate(user, filter))) + .then(adverts => ([...adverts].sort(createAdvertFilterComparer(user, filter)))) .catch(e => { if (e?.code === 'ENOENT') { return [] @@ -39,8 +41,8 @@ export const createFsAdvertsRepository = (dataFolder: string): AdvertsRepository return advert } - const update: AdvertsRepository['update'] = async (id, user, input) => { - const existing = await getAdvert(id) + const update: AdvertsRepository['update'] = async (user, id, input) => { + const existing = await getAdvert(user, id) if (!existing) { return null } @@ -51,8 +53,8 @@ export const createFsAdvertsRepository = (dataFolder: string): AdvertsRepository return updated } - const remove: AdvertsRepository['remove'] = async (id) => { - const existing = await getAdvert(id) + const remove: AdvertsRepository['remove'] = async (user, id) => { + const existing = await getAdvert(user, id) if (existing) { const path = join(dataFolder, `${id}.json`) await unlink(path) @@ -60,9 +62,9 @@ export const createFsAdvertsRepository = (dataFolder: string): AdvertsRepository return existing } - const saveAdvertVersion: AdvertsRepository['saveAdvertVersion'] = async (versionid, advert) => { + const saveAdvertVersion: AdvertsRepository['saveAdvertVersion'] = async (user, versionid, advert) => { const { id } = advert - const existing = await getAdvert(id) + const existing = await getAdvert(user, id) if (existing && (existing.versionId === versionid)) { const path = join(dataFolder, `${id}.json`) await mkdirp(dataFolder) diff --git a/src/adverts/in-memory-adverts-repository/index.ts b/src/adverts/in-memory-adverts-repository/index.ts index b821ec3..ad1b0da 100644 --- a/src/adverts/in-memory-adverts-repository/index.ts +++ b/src/adverts/in-memory-adverts-repository/index.ts @@ -1,17 +1,18 @@ import type { Advert, AdvertsRepository } from '../types' -import { createFilterPredicate } from '../filters/create-filter-predicate' +import { createAdvertFilterPredicate } from '../filters/advert-filter-predicate' import { mapCreateAdvertInputToAdvert, patchAdvertWithAdvertInput } from '../mappers' +import { createAdvertFilterComparer } from '../filters/advert-filter-sorter' export const createInMemoryAdvertsRepository = (db: Record = {}): AdvertsRepository => ({ - getAdvert: async id => db[id] || null, - list: async (filter) => Object.values(db).filter(createFilterPredicate(filter)), + getAdvert: async (user, id) => db[id] || null, + list: async (user, filter) => Object.values(db).filter(createAdvertFilterPredicate(user, filter)).sort(createAdvertFilterComparer(user, filter)), create: async (user, input) => { const advert = mapCreateAdvertInputToAdvert(input, user) // eslint-disable-next-line no-param-reassign db[advert.id] = advert return advert }, - update: async (id, user, input) => { + update: async (user, id, input) => { const existing = db[id] if (existing) { // eslint-disable-next-line no-param-reassign @@ -20,13 +21,13 @@ export const createInMemoryAdvertsRepository = (db: Record = {}) } return null }, - remove: async (id) => { + remove: async (user, id) => { const existing = db[id] // eslint-disable-next-line no-param-reassign delete db[id] return existing }, - saveAdvertVersion: async (versionId, advert) => { + saveAdvertVersion: async (user, versionId, advert) => { const { id } = advert if (db[id]?.versionId === versionId) { // eslint-disable-next-line no-param-reassign diff --git a/src/adverts/types.ts b/src/adverts/types.ts index 7c6beaa..24de031 100644 --- a/src/adverts/types.ts +++ b/src/adverts/types.ts @@ -88,20 +88,37 @@ export type FilterInput = { lte?: T } & (T extends string ? {contains?: string} : Record) -export type FilterAdvertsInput = { +export type AdvertFieldsFilterInput = { id?: FilterInput } & { - [Property in keyof Omit]: FilterInput + [Property in keyof Omit]?: FilterInput +} + +export interface AdvertRestrictionsFilterInput { + canBeReserved?: boolean, + reservedByMe?: boolean, + createdByMe?: boolean +} + +export interface AdvertSorting { + field?: keyof AdvertUserFields + ascending?: boolean +} +export interface AdvertFilterInput { + search?: string + fields?: AdvertFieldsFilterInput + restrictions?: AdvertRestrictionsFilterInput + sorting: AdvertSorting } export interface AdvertsRepository { - getAdvert: (id: string) => Promise - saveAdvertVersion: (versionId: string, advert: Advert) => Promise, - list: (filter?: FilterAdvertsInput) => Promise + getAdvert: (user: HaffaUser, id: string) => Promise + saveAdvertVersion: (user: HaffaUser, versionId: string, advert: Advert) => Promise, + list: (user: HaffaUser, filter?: AdvertFilterInput) => Promise create: (user: HaffaUser, advert: AdvertInput) => Promise - update: (id: string, user: HaffaUser, advert: AdvertInput) => Promise - remove: (id: string) => Promise + update: (user: HaffaUser, id: string, advert: AdvertInput) => Promise + remove: (user: HaffaUser, id: string) => Promise } export interface AdvertMutations {