From 3bf32e5dc9ad3150762b9bb744b0453d3640e204 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Thu, 3 Feb 2022 19:03:15 +0100 Subject: [PATCH] fix: adds order by functionality to products (#1021) * fix: adds order by functionality to products * feat: adds product tags list * fix: adds client and react support for product tags * fix: unit test * Update packages/medusa/src/services/product-tag.ts Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> * Update packages/medusa/src/services/product-tag.ts Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> * Update packages/medusa/src/services/product-tag.ts Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> * Update packages/medusa/src/services/product-tag.ts Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> * Update packages/medusa/src/services/product-tag.ts Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> * Update packages/medusa/src/services/product-tag.ts Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> --- .../admin/__snapshots__/product-tag.js.snap | 24 ++++ .../api/__tests__/admin/product-tag.js | 72 ++++++++++ .../medusa-js/src/resources/admin/index.ts | 2 + .../src/resources/admin/product-tags.ts | 24 ++++ .../medusa-react/src/hooks/admin/index.ts | 1 + .../src/hooks/admin/product-tags/index.ts | 1 + .../src/hooks/admin/product-tags/queries.ts | 34 +++++ .../src/hooks/admin/products/queries.ts | 2 +- .../test/hooks/admin/products/queries.test.ts | 6 +- packages/medusa/src/api/index.js | 1 + packages/medusa/src/api/routes/admin/index.js | 2 + .../api/routes/admin/product-tags/index.ts | 40 ++++++ .../admin/product-tags/list-product-tags.ts | 124 ++++++++++++++++++ .../routes/admin/products/list-products.ts | 41 ++++-- packages/medusa/src/services/product-tag.ts | 115 ++++++++++++++++ packages/medusa/src/types/common.ts | 2 +- packages/medusa/src/types/product.ts | 20 +++ 17 files changed, 498 insertions(+), 13 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap create mode 100644 integration-tests/api/__tests__/admin/product-tag.js create mode 100644 packages/medusa-js/src/resources/admin/product-tags.ts create mode 100644 packages/medusa-react/src/hooks/admin/product-tags/index.ts create mode 100644 packages/medusa-react/src/hooks/admin/product-tags/queries.ts create mode 100644 packages/medusa/src/api/routes/admin/product-tags/index.ts create mode 100644 packages/medusa/src/api/routes/admin/product-tags/list-product-tags.ts create mode 100644 packages/medusa/src/services/product-tag.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap new file mode 100644 index 0000000000..9ae6dd7e62 --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`/admin/product-tags GET /admin/product-tags returns a list of product tags 1`] = ` +Array [ + Object { + "created_at": Any, + "id": "tag1", + "updated_at": Any, + "value": "123", + }, + Object { + "created_at": Any, + "id": "tag3", + "updated_at": Any, + "value": "123", + }, + Object { + "created_at": Any, + "id": "tag4", + "updated_at": Any, + "value": "123", + }, +] +`; diff --git a/integration-tests/api/__tests__/admin/product-tag.js b/integration-tests/api/__tests__/admin/product-tag.js new file mode 100644 index 0000000000..6110cdcd97 --- /dev/null +++ b/integration-tests/api/__tests__/admin/product-tag.js @@ -0,0 +1,72 @@ +const path = require("path") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") + +const adminSeeder = require("../../helpers/admin-seeder") +const productSeeder = require("../../helpers/product-seeder") + +jest.setTimeout(50000) + +describe("/admin/product-tags", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("GET /admin/product-tags", () => { + beforeEach(async () => { + try { + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("returns a list of product tags", async () => { + const api = useApi() + + const res = await api + .get("/admin/product-tags", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(res.status).toEqual(200) + + const tagMatch = { + created_at: expect.any(String), + updated_at: expect.any(String), + } + + expect(res.data.product_tags).toMatchSnapshot([ + tagMatch, + tagMatch, + tagMatch, + ]) + }) + }) +}) diff --git a/packages/medusa-js/src/resources/admin/index.ts b/packages/medusa-js/src/resources/admin/index.ts index d2939b4033..2b6e7079f0 100644 --- a/packages/medusa-js/src/resources/admin/index.ts +++ b/packages/medusa-js/src/resources/admin/index.ts @@ -20,6 +20,7 @@ import AdminShippingOptionsResource from "./shipping-options" import AdminRegionsResource from "./regions" import AdminNotificationsResource from "./notifications" import AdminUploadsResource from "./uploads" +import AdminProductTagsResource from "./product-tags" class Admin extends BaseResource { public auth = new AdminAuthResource(this.client) @@ -31,6 +32,7 @@ class Admin extends BaseResource { public invites = new AdminInvitesResource(this.client) public notes = new AdminNotesResource(this.client) public products = new AdminProductsResource(this.client) + public productTags = new AdminProductTagsResource(this.client) public users = new AdminUsersResource(this.client) public returns = new AdminReturnsResource(this.client) public orders = new AdminOrdersResource(this.client) diff --git a/packages/medusa-js/src/resources/admin/product-tags.ts b/packages/medusa-js/src/resources/admin/product-tags.ts new file mode 100644 index 0000000000..529490f8b3 --- /dev/null +++ b/packages/medusa-js/src/resources/admin/product-tags.ts @@ -0,0 +1,24 @@ +import { + AdminGetProductTagsParams, + AdminProductTagsListRes, +} from "@medusajs/medusa" +import qs from "qs" +import { ResponsePromise } from "../../typings" +import BaseResource from "../base" + +class AdminProductTagsResource extends BaseResource { + list( + query?: AdminGetProductTagsParams + ): ResponsePromise { + let path = `/admin/product-tags` + + if (query) { + const queryString = qs.stringify(query) + path = `/admin/product-tags?${queryString}` + } + + return this.client.request("GET", path) + } +} + +export default AdminProductTagsResource diff --git a/packages/medusa-react/src/hooks/admin/index.ts b/packages/medusa-react/src/hooks/admin/index.ts index d2c074a566..c36cc5f3bd 100644 --- a/packages/medusa-react/src/hooks/admin/index.ts +++ b/packages/medusa-react/src/hooks/admin/index.ts @@ -7,6 +7,7 @@ export * from "./draft-orders" export * from "./gift-cards" export * from "./orders" export * from "./products" +export * from "./product-tags" export * from "./return-reasons" export * from "./regions" export * from "./shipping-options" diff --git a/packages/medusa-react/src/hooks/admin/product-tags/index.ts b/packages/medusa-react/src/hooks/admin/product-tags/index.ts new file mode 100644 index 0000000000..f3593df2df --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/product-tags/index.ts @@ -0,0 +1 @@ +export * from "./queries" diff --git a/packages/medusa-react/src/hooks/admin/product-tags/queries.ts b/packages/medusa-react/src/hooks/admin/product-tags/queries.ts new file mode 100644 index 0000000000..7fc25e327c --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/product-tags/queries.ts @@ -0,0 +1,34 @@ +import { + AdminProductTagsListRes, + AdminGetProductTagsParams, +} from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { useQuery } from "react-query" +import { useMedusa } from "../../../contexts" +import { UseQueryOptionsWrapper } from "../../../types" +import { queryKeysFactory } from "../../utils/index" + +const ADMIN_PRODUCT_TAGS_QUERY_KEY = `admin_product_tags` as const + +export const adminProductTagKeys = queryKeysFactory( + ADMIN_PRODUCT_TAGS_QUERY_KEY +) + +type ProductQueryKeys = typeof adminProductTagKeys + +export const useAdminProductTags = ( + query?: AdminGetProductTagsParams, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + const { data, ...rest } = useQuery( + adminProductTagKeys.list(query), + () => client.admin.productTags.list(query), + options + ) + return { ...data, ...rest } as const +} diff --git a/packages/medusa-react/src/hooks/admin/products/queries.ts b/packages/medusa-react/src/hooks/admin/products/queries.ts index 28cdb1bd0d..759856e6bc 100644 --- a/packages/medusa-react/src/hooks/admin/products/queries.ts +++ b/packages/medusa-react/src/hooks/admin/products/queries.ts @@ -67,7 +67,7 @@ export const useAdminProductTypes = ( return { ...data, ...rest } as const } -export const useAdminProductTags = ( +export const useAdminProductTagUsage = ( options?: UseQueryOptionsWrapper< Response, Error, diff --git a/packages/medusa-react/test/hooks/admin/products/queries.test.ts b/packages/medusa-react/test/hooks/admin/products/queries.test.ts index f61869e811..769a4634c6 100644 --- a/packages/medusa-react/test/hooks/admin/products/queries.test.ts +++ b/packages/medusa-react/test/hooks/admin/products/queries.test.ts @@ -1,7 +1,7 @@ import { useAdminProduct, useAdminProducts, - useAdminProductTags, + useAdminProductTagUsage, useAdminProductTypes, } from "../../../../src" import { renderHook } from "@testing-library/react-hooks" @@ -36,10 +36,10 @@ describe("useAdminProductTypes hook", () => { }) }) -describe("useAdminProductTags hook", () => { +describe("useAdminProductTagUsage hook", () => { test("returns a list of product tags", async () => { const tags = fixtures.list("product_tag") - const { result, waitFor } = renderHook(() => useAdminProductTags(), { + const { result, waitFor } = renderHook(() => useAdminProductTagUsage(), { wrapper: createWrapper(), }) diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index dcf2858dd8..da7d036f6f 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -37,6 +37,7 @@ export * from "./routes/admin/uploads" export * from "./routes/admin/returns" export * from "./routes/admin/shipping-options" export * from "./routes/admin/regions" +export * from "./routes/admin/product-tags" // Store export * from "./routes/store/auth" diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index fe04926ada..ad1c1ee690 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -22,6 +22,7 @@ import returnRoutes from "./returns" import variantRoutes from "./variants" import draftOrderRoutes from "./draft-orders" import collectionRoutes from "./collections" +import productTagRoutes from "./product-tags" import notificationRoutes from "./notifications" import noteRoutes from "./notes" @@ -76,6 +77,7 @@ export default (app, container, config) => { collectionRoutes(route) notificationRoutes(route) returnReasonRoutes(route) + productTagRoutes(route) noteRoutes(route) inviteRoutes(route) diff --git a/packages/medusa/src/api/routes/admin/product-tags/index.ts b/packages/medusa/src/api/routes/admin/product-tags/index.ts new file mode 100644 index 0000000000..f7becf15ae --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-tags/index.ts @@ -0,0 +1,40 @@ +import { Router } from "express" +import { ProductTag } from "../../../.." +import { PaginatedResponse } from "../../../../types/common" +import middlewares from "../../../middlewares" +import "reflect-metadata" + +const route = Router() + +export default (app) => { + app.use("/product-tags", route) + + route.get("/", middlewares.wrap(require("./list-product-tags").default)) + + return app +} + +export const allowedAdminProductTagsFields = [ + "id", + "value", + "created_at", + "updated_at", +] + +export const defaultAdminProductTagsFields = [ + "id", + "value", + "created_at", + "updated_at", +] +export const defaultAdminProductTagsRelations = [] + +export type AdminProductTagsListRes = PaginatedResponse & { + product_tags: ProductTag[] +} + +export type AdminProductTagsRes = { + product_tag: ProductTag +} + +export * from "./list-product-tags" diff --git a/packages/medusa/src/api/routes/admin/product-tags/list-product-tags.ts b/packages/medusa/src/api/routes/admin/product-tags/list-product-tags.ts new file mode 100644 index 0000000000..ae24764bf1 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-tags/list-product-tags.ts @@ -0,0 +1,124 @@ +import { Type } from "class-transformer" +import { MedusaError } from "medusa-core-utils" +import { IsNumber, IsString, IsOptional, ValidateNested } from "class-validator" +import { omit, pickBy, identity } from "lodash" +import { + allowedAdminProductTagsFields, + defaultAdminProductTagsFields, + defaultAdminProductTagsRelations, +} from "." +import { ProductTag } from "../../../../models/product-tag" +import ProductTagService from "../../../../services/product-tag" +import { + StringComparisonOperator, + DateComparisonOperator, + FindConfig, +} from "../../../../types/common" +import { validator } from "../../../../utils/validator" +import { IsType } from "../../../../utils/validators/is-type" + +/** + * @oas [get] /product-tags + * operationId: "GetProductTags" + * summary: "List Product Tags" + * description: "Retrieve a list of Product Tags." + * x-authenticated: true + * parameters: + * - (query) limit {string} The number of tags to return. + * - (query) offset {string} The offset of tags to return. + * - (query) value {string} The value of tags to return. + * - (query) id {string} The id of tags to return. + * - (query) created_at {DateComparisonOperator} Date comparison for when resulting tas was created, i.e. less than, greater than etc. + * - (query) updated_at {DateComparisonOperator} Date comparison for when resulting tas was updated, i.e. less than, greater than etc. + * tags: + * - Product Tag + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * properties: + * tags: + * $ref: "#/components/schemas/product_tag" + */ +export default async (req, res) => { + const validated = await validator(AdminGetProductTagsParams, req.query) + + const tagService: ProductTagService = req.scope.resolve("productTagService") + + const listConfig: FindConfig = { + select: defaultAdminProductTagsFields as (keyof ProductTag)[], + relations: defaultAdminProductTagsRelations, + skip: validated.offset, + take: validated.limit, + } + + if (typeof validated.order !== "undefined") { + let orderField = validated.order + if (validated.order.startsWith("-")) { + const [, field] = validated.order.split("-") + orderField = field + listConfig.order = { [field]: "DESC" } + } else { + listConfig.order = { [validated.order]: "ASC" } + } + + if (!allowedAdminProductTagsFields.includes(orderField)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Order field must be a valid product tag field" + ) + } + } + + const filterableFields = omit(validated, ["limit", "offset"]) + + const [tags, count] = await tagService.listAndCount( + pickBy(filterableFields, identity), + listConfig + ) + + res.status(200).json({ + product_tags: tags, + count, + offset: validated.offset, + limit: validated.limit, + }) +} + +export class AdminGetProductTagsPaginationParams { + @IsNumber() + @IsOptional() + @Type(() => Number) + limit = 10 + + @IsNumber() + @IsOptional() + @Type(() => Number) + offset = 0 +} + +export class AdminGetProductTagsParams extends AdminGetProductTagsPaginationParams { + @ValidateNested() + @IsType([String, [String], StringComparisonOperator]) + @IsOptional() + id?: string | string[] | StringComparisonOperator + + @ValidateNested() + @IsType([String, [String], StringComparisonOperator]) + @IsOptional() + value?: string | string[] | StringComparisonOperator + + @IsType([DateComparisonOperator]) + @IsOptional() + created_at?: DateComparisonOperator + + @IsType([DateComparisonOperator]) + @IsOptional() + updated_at?: DateComparisonOperator + + @IsString() + @IsOptional() + order?: string +} diff --git a/packages/medusa/src/api/routes/admin/products/list-products.ts b/packages/medusa/src/api/routes/admin/products/list-products.ts index 7b03a314c3..66aef90771 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.ts +++ b/packages/medusa/src/api/routes/admin/products/list-products.ts @@ -8,11 +8,16 @@ import { IsString, ValidateNested, } from "class-validator" -import * as _ from "lodash" -import { identity } from "lodash" -import { defaultAdminProductFields, defaultAdminProductRelations } from "." +import { pickBy, omit } from "lodash" +import { MedusaError } from "medusa-core-utils" +import { Product } from "../../../../models/product" +import { + allowedAdminProductFields, + defaultAdminProductFields, + defaultAdminProductRelations, +} from "." import { ProductService } from "../../../../services" -import { DateComparisonOperator } from "../../../../types/common" +import { FindConfig, DateComparisonOperator } from "../../../../types/common" import { validator } from "../../../../utils/validator" /** @@ -78,8 +83,10 @@ export default async (req, res) => { expandFields = validatedParams.expand!.split(",") } - const listConfig = { - select: includeFields.length ? includeFields : defaultAdminProductFields, + const listConfig: FindConfig = { + select: (includeFields.length + ? includeFields + : defaultAdminProductFields) as (keyof Product)[], relations: expandFields.length ? expandFields : defaultAdminProductRelations, @@ -87,7 +94,25 @@ export default async (req, res) => { take: validatedParams.limit, } - const filterableFields = _.omit(validatedParams, [ + if (typeof validatedParams.order !== "undefined") { + let orderField = validatedParams.order + if (validatedParams.order.startsWith("-")) { + const [, field] = validatedParams.order.split("-") + orderField = field + listConfig.order = { [field]: "DESC" } + } else { + listConfig.order = { [validatedParams.order]: "ASC" } + } + + if (!allowedAdminProductFields.includes(orderField)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Order field must be a valid product field" + ) + } + } + + const filterableFields = omit(validatedParams, [ "limit", "offset", "expand", @@ -96,7 +121,7 @@ export default async (req, res) => { ]) const [products, count] = await productService.listAndCount( - _.pickBy(filterableFields, (val) => typeof val !== "undefined"), + pickBy(filterableFields, (val) => typeof val !== "undefined"), listConfig ) diff --git a/packages/medusa/src/services/product-tag.ts b/packages/medusa/src/services/product-tag.ts new file mode 100644 index 0000000000..b76a99dc26 --- /dev/null +++ b/packages/medusa/src/services/product-tag.ts @@ -0,0 +1,115 @@ +import { EntityManager } from "typeorm" +import { BaseService } from "medusa-interfaces" +import { MedusaError } from "medusa-core-utils" +import { ProductTagRepository } from "../repositories/product-tag" +import { ProductTag } from "../models/product-tag" +import { FindConfig } from "../types/common" +import { FilterableProductTagProps } from "../types/product" + +type ProductTagConstructorProps = { + manager: EntityManager + productTagRepository: typeof ProductTagRepository +} + +/** + * Provides layer to manipulate product tags. + * @extends BaseService + */ +class ProductTagService extends BaseService { + private manager_: EntityManager + private tagRepo_: typeof ProductTagRepository + + constructor({ manager, productTagRepository }: ProductTagConstructorProps) { + super() + this.manager_ = manager + this.tagRepo_ = productTagRepository + } + + withTransaction(transactionManager: EntityManager): ProductTagService { + if (!transactionManager) { + return this + } + + const cloned = new ProductTagService({ + manager: transactionManager, + productTagRepository: this.tagRepo_, + }) + + cloned.transactionManager_ = transactionManager + + return cloned + } + + /** + * Retrieves a product tag by id. + * @param {string} tagId - the id of the product tag to retrieve + * @param {Object} config - the config to retrieve the tag by + * @return {Promise} the collection. + */ + async retrieve( + tagId: string, + config: FindConfig = {} + ): Promise { + const tagRepo = this.manager_.getCustomRepository(this.tagRepo_) + + const query = this.buildQuery_({ id: tagId }, config) + const tag = await tagRepo.findOne(query) + + if (!tag) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product tag with id: ${tagId} was not found` + ) + } + + return tag + } + + /** + * Creates a product tag + * @param {object} tag - the product tag to create + * @return {Promise} created product tag + */ + async create(tag: Partial): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const tagRepo = manager.getCustomRepository(this.tagRepo_) + + const productTag = tagRepo.create(tag) + return await tagRepo.save(productTag) + }) + } + + /** + * Lists product tags + * @param {Object} selector - the query object for find + * @param {Object} config - the config to be used for find + * @return {Promise} the result of the find operation + */ + async list( + selector: FilterableProductTagProps = {}, + config: FindConfig = { skip: 0, take: 20 } + ): Promise { + const tagRepo = this.manager_.getCustomRepository(this.tagRepo_) + + const query = this.buildQuery_(selector, config) + return await tagRepo.find(query) + } + + /** + * Lists product tags and adds count. + * @param {Object} selector - the query object for find + * @param {Object} config - the config to be used for find + * @return {Promise} the result of the find operation + */ + async listAndCount( + selector: FilterableProductTagProps = {}, + config: FindConfig = { skip: 0, take: 20 } + ): Promise<[ProductTag[], number]> { + const tagRepo = this.manager_.getCustomRepository(this.tagRepo_) + + const query = this.buildQuery_(selector, config) + return await tagRepo.findAndCount(query) + } +} + +export default ProductTagService diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index abbd8a7ce7..a8d88a2939 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -23,7 +23,7 @@ export interface FindConfig { skip?: number take?: number relations?: string[] - order?: "ASC" | "DESC" + order?: { [k: string]: "ASC" | "DESC" } } export type PaginatedResponse = { limit: number; offset: number; count: number } diff --git a/packages/medusa/src/types/product.ts b/packages/medusa/src/types/product.ts index a00fdcf0d0..c0175d1684 100644 --- a/packages/medusa/src/types/product.ts +++ b/packages/medusa/src/types/product.ts @@ -1,6 +1,26 @@ +import { ValidateNested } from "class-validator" +import { IsType } from "../utils/validators/is-type" +import { DateComparisonOperator, StringComparisonOperator } from "./common" + export enum ProductStatus { DRAFT = "draft", PROPOSED = "proposed", PUBLISHED = "published", REJECTED = "rejected", } + +export class FilterableProductTagProps { + @ValidateNested() + @IsType([String, [String], StringComparisonOperator]) + id?: string | string[] | StringComparisonOperator + + @ValidateNested() + @IsType([String, [String], StringComparisonOperator]) + value?: string | string[] | StringComparisonOperator + + @IsType([DateComparisonOperator]) + created_at?: DateComparisonOperator + + @IsType([DateComparisonOperator]) + updated_at?: DateComparisonOperator +}