diff --git a/packages/card-service/knexfile.js b/packages/card-service/knexfile.js index 6f8e2606ed..cff04064bc 100644 --- a/packages/card-service/knexfile.js +++ b/packages/card-service/knexfile.js @@ -13,7 +13,7 @@ module.exports = { max: 10 }, migrations: { - tableName: 'knex_migrations' + tableName: 'card_service_knex_migrations' } }, @@ -29,7 +29,7 @@ module.exports = { max: 10 }, migrations: { - tableName: 'knex_migrations' + tableName: 'card_service_knex_migrations' } }, @@ -41,7 +41,7 @@ module.exports = { max: 10 }, migrations: { - tableName: 'knex_migrations' + tableName: 'card_service_knex_migrations' } } } diff --git a/packages/card-service/migrations/20250708070327_card_payments_table.js b/packages/card-service/migrations/20250708070327_card_payments_table.js new file mode 100644 index 0000000000..a1adf8d8c0 --- /dev/null +++ b/packages/card-service/migrations/20250708070327_card_payments_table.js @@ -0,0 +1,34 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('cardPayments', function (table) { + table.uuid('id').notNullable().primary() + table.uuid('requestId').notNullable() + + table.timestamp('requestedAt').defaultTo(knex.fn.now()) + table.timestamp('finalizedAt').defaultTo(knex.fn.now()) + + table.string('cardWalletAddress').notNullable() + table.string('incomingPaymentUrl').notNullable() + + table.integer('statusCode').nullable() + + table.uuid('outgoingPaymentId') + table.uuid('terminalId') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + + table.index('cardWalletAddress') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('cardPayments') +} diff --git a/packages/card-service/package.json b/packages/card-service/package.json index aeadc35993..39fafcbf8a 100644 --- a/packages/card-service/package.json +++ b/packages/card-service/package.json @@ -18,13 +18,16 @@ "knex": "^3.1.0", "koa": "^2.15.4", "objection": "^3.1.5", - "pino": "^8.19.0" + "objection-db-errors": "^1.1.2", + "pino": "^8.19.0", + "uuid": "^9.0.1" }, "devDependencies": { "@types/koa": "2.15.0", "@types/koa-bodyparser": "^4.3.12", "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.4", + "@types/uuid": "^9.0.8", "ts-node-dev": "^2.0.0" } } diff --git a/packages/card-service/src/card-payment/model.ts b/packages/card-service/src/card-payment/model.ts new file mode 100644 index 0000000000..704aeb49d3 --- /dev/null +++ b/packages/card-service/src/card-payment/model.ts @@ -0,0 +1,16 @@ +import { BaseModel } from '../shared/baseModel' + +export class CardPayment extends BaseModel { + public static get tableName(): string { + return 'card_payments' + } + + public requestId!: string + public requestedAt!: Date | null + public finalizedAt!: Date | null + public cardWalletAddress!: string + public incomingPaymentUrl?: string + public statusCode?: number + public outgoingPaymentId?: string + public terminalId!: string +} diff --git a/packages/card-service/src/shared/baseModel.test.ts b/packages/card-service/src/shared/baseModel.test.ts new file mode 100644 index 0000000000..cfb256ec25 --- /dev/null +++ b/packages/card-service/src/shared/baseModel.test.ts @@ -0,0 +1,128 @@ +import { BaseModel, Pagination, SortOrder } from './baseModel' +import { getPageInfo } from './pagination' + +interface PageTestsOptions { + createModel: () => Promise + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise +} + +export const getPageTests = ({ + createModel, + getPage +}: PageTestsOptions): void => { + describe('Common BaseModel pagination', (): void => { + let modelsCreated: Type[] + + beforeEach(async (): Promise => { + modelsCreated = [] + for (let i = 0; i < 22; i++) { + modelsCreated.push(await createModel()) + } + modelsCreated.reverse() // default sort order is DESC + }) + + test.each` + pagination | expected | description + ${undefined} | ${{ length: 20, first: 0, last: 19 }} | ${'Defaults to fetching first 20 items'} + ${{ first: 10 }} | ${{ length: 10, first: 0, last: 9 }} | ${'Can change forward pagination limit'} + ${{ after: 0 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Can paginate forwards from a cursor'} + ${{ first: 10, after: 9 }} | ${{ length: 10, first: 10, last: 19 }} | ${'Can paginate forwards from a cursor with a limit'} + ${{ before: 20 }} | ${{ length: 20, first: 0, last: 19 }} | ${'Can paginate backwards from a cursor'} + ${{ last: 5, before: 10 }} | ${{ length: 5, first: 5, last: 9 }} | ${'Can paginate backwards from a cursor with a limit'} + ${{ after: 0, before: 19 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Providing before and after results in forward pagination'} + `('$description', async ({ pagination, expected }): Promise => { + if (pagination?.after !== undefined) { + pagination.after = modelsCreated[pagination.after].id + } + if (pagination?.before !== undefined) { + pagination.before = modelsCreated[pagination.before].id + } + const models = await getPage(pagination) + expect(models).toHaveLength(expected.length) + expect(models[0].id).toEqual(modelsCreated[expected.first].id) + expect(models[expected.length - 1].id).toEqual( + modelsCreated[expected.last].id + ) + }) + + test.each` + pagination | expectedError | description + ${{ last: 10 }} | ${"Can't paginate backwards from the start."} | ${"Can't change backward pagination limit on it's own."} + ${{ first: -1 }} | ${'Pagination index error'} | ${"Can't request less than 0"} + ${{ first: 101 }} | ${'Pagination index error'} | ${"Can't request more than 100"} + `('$description', async ({ pagination, expectedError }): Promise => { + await expect(getPage(pagination)).rejects.toThrow(expectedError) + }) + + test.each` + order | description + ${SortOrder.Asc} | ${'Backwards/Forwards pagination results in same order for ASC.'} + ${SortOrder.Desc} | ${'Backwards/Forwards pagination results in same order for DESC.'} + `('$description', async ({ order }): Promise => { + if (order === SortOrder.Asc) { + // model was in DESC order so needs to be reverted back to ASC + modelsCreated.reverse() + } + const paginationForwards = { + first: 10 + } + const modelsForwards = await getPage(paginationForwards, order) + const paginationBackwards = { + last: 10, + before: modelsCreated[10].id + } + const modelsBackwards = await getPage(paginationBackwards, order) + expect(modelsForwards).toHaveLength(10) + expect(modelsBackwards).toHaveLength(10) + expect(modelsForwards).toEqual(modelsBackwards) + }) + + test.each` + pagination | cursor | start | end | hasNextPage | hasPreviousPage | sortOrder + ${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Desc} + ${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Desc} + ${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Desc} + ${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Desc} + ${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Desc} + ${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Asc} + ${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Asc} + ${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Asc} + ${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Asc} + ${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Asc} + `( + 'pagination $pagination with cursor $cursor in $sortOrder order', + async ({ + pagination, + cursor, + start, + end, + hasNextPage, + hasPreviousPage, + sortOrder + }): Promise => { + if (sortOrder === SortOrder.Asc) { + modelsCreated.reverse() + } + if (cursor) { + if (pagination.last) pagination.before = modelsCreated[cursor].id + else pagination.after = modelsCreated[cursor].id + } + + const page = await getPage(pagination, sortOrder) + const pageInfo = await getPageInfo({ + getPage: (pagination, sortOrder) => getPage(pagination, sortOrder), + page, + sortOrder + }) + expect(pageInfo).toEqual({ + startCursor: modelsCreated[start].id, + endCursor: modelsCreated[end].id, + hasNextPage, + hasPreviousPage + }) + } + ) + }) +} + +test.todo('test suite must contain at least one test') diff --git a/packages/card-service/src/shared/baseModel.ts b/packages/card-service/src/shared/baseModel.ts new file mode 100644 index 0000000000..1b361238d1 --- /dev/null +++ b/packages/card-service/src/shared/baseModel.ts @@ -0,0 +1,144 @@ +import { + Model, + ModelOptions, + Page, + Pojo, + QueryBuilder, + QueryContext +} from 'objection' +import { DbErrors } from 'objection-db-errors' +import { v4 as uuid } from 'uuid' + +export interface Pagination { + after?: string // Forward pagination: cursor. + before?: string // Backward pagination: cursor. + first?: number // Forward pagination: limit. + last?: number // Backward pagination: limit. +} + +export interface PageInfo { + startCursor?: string + endCursor?: string + hasNextPage: boolean + hasPreviousPage: boolean +} + +export enum SortOrder { + Asc = 'ASC', + Desc = 'DESC' +} + +class PaginationQueryBuilder extends QueryBuilder< + M, + R +> { + ArrayQueryBuilderType!: PaginationQueryBuilder + SingleQueryBuilderType!: PaginationQueryBuilder + MaybeSingleQueryBuilderType!: PaginationQueryBuilder + NumberQueryBuilderType!: PaginationQueryBuilder + PageQueryBuilderType!: PaginationQueryBuilder> + + /** TODO: Base64 encode/decode the cursors + * Buffer.from("Hello World").toString('base64') + * Buffer.from("SGVsbG8gV29ybGQ=", 'base64').toString('ascii') + */ + + /** getPage + * The pagination algorithm is based on the Relay connection specification. + * Please read the spec before changing things: + * https://relay.dev/graphql/connections.htm + * @param pagination Pagination - cursors and limits. + * @param sortOrder SortOrder - Asc/Desc sort order. + * @returns Model[] An array of Models that form a page. + */ + getPage( + pagination?: Pagination, + sortOrder: SortOrder = SortOrder.Desc + ): this { + const tableName = this.modelClass().tableName + if ( + typeof pagination?.before === 'undefined' && + typeof pagination?.last === 'number' + ) + throw new Error("Can't paginate backwards from the start.") + + const first = pagination?.first || 20 + if (first < 0 || first > 100) throw new Error('Pagination index error') + const last = pagination?.last || 20 + if (last < 0 || last > 100) throw new Error('Pagination index error') + /** + * Forward pagination + */ + if (typeof pagination?.after === 'string') { + const comparisonOperator = sortOrder === SortOrder.Asc ? '>' : '<' + return this.whereRaw( + `("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, + [this.modelClass().tableName, pagination.after] + ) + .orderBy([ + { column: 'createdAt', order: sortOrder }, + { column: 'id', order: sortOrder } + ]) + .limit(first) + } + /** + * Backward pagination + */ + if (typeof pagination?.before === 'string') { + const comparisonOperator = sortOrder === SortOrder.Asc ? '<' : '>' + const order = sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc + return this.whereRaw( + `("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, + [this.modelClass().tableName, pagination.before] + ) + .orderBy([ + { column: 'createdAt', order }, + { column: 'id', order } + ]) + .limit(last) + .runAfter((models) => { + if (Array.isArray(models)) { + return models.reverse() + } + }) + } + + return this.orderBy([ + { column: 'createdAt', order: sortOrder }, + { column: 'id', order: sortOrder } + ]).limit(first) + } +} + +export class PaginationModel extends DbErrors(Model) { + QueryBuilderType!: PaginationQueryBuilder + static QueryBuilder = PaginationQueryBuilder +} + +export abstract class BaseModel extends PaginationModel { + public static get modelPaths(): string[] { + return [__dirname] + } + + public id!: string + public createdAt!: Date + public updatedAt!: Date + + public $beforeInsert(context: QueryContext): void { + super.$beforeInsert(context) + this.id = this.id || uuid() + } + + public $beforeUpdate(_opts: ModelOptions, _queryContext: QueryContext): void { + this.updatedAt = new Date() + } + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + createdAt: json.createdAt.toISOString(), + updatedAt: json.updatedAt.toISOString() + } + } +} diff --git a/packages/card-service/src/shared/baseService.ts b/packages/card-service/src/shared/baseService.ts new file mode 100644 index 0000000000..3254a736e2 --- /dev/null +++ b/packages/card-service/src/shared/baseService.ts @@ -0,0 +1,8 @@ +import { TransactionOrKnex } from 'objection' + +import { Logger } from 'pino' + +export interface BaseService { + logger: Logger + knex?: TransactionOrKnex +} diff --git a/packages/card-service/src/shared/filters.ts b/packages/card-service/src/shared/filters.ts new file mode 100644 index 0000000000..dcfeb6c0bc --- /dev/null +++ b/packages/card-service/src/shared/filters.ts @@ -0,0 +1,3 @@ +export interface FilterString { + in?: string[] +} diff --git a/packages/card-service/src/shared/pagination.ts b/packages/card-service/src/shared/pagination.ts new file mode 100644 index 0000000000..06a4bfc4e2 --- /dev/null +++ b/packages/card-service/src/shared/pagination.ts @@ -0,0 +1,53 @@ +import { BaseModel, PageInfo, Pagination, SortOrder } from './baseModel' + +type GetPageInfoArgs = { + getPage: (pagination: Pagination, sortOrder?: SortOrder) => Promise + page: T[] + sortOrder?: SortOrder +} + +export async function getPageInfo({ + getPage, + page, + sortOrder +}: GetPageInfoArgs): Promise { + if (page.length == 0) + return { + hasPreviousPage: false, + hasNextPage: false + } + const firstId = page[0].id + const lastId = page[page.length - 1].id + + let hasNextPage, hasPreviousPage + + try { + hasNextPage = await getPage( + { + after: lastId, + first: 1 + }, + sortOrder + ) + } catch (e) { + hasNextPage = [] + } + try { + hasPreviousPage = await getPage( + { + before: firstId, + last: 1 + }, + sortOrder + ) + } catch (e) { + hasPreviousPage = [] + } + + return { + endCursor: lastId, + hasNextPage: hasNextPage.length == 1, + hasPreviousPage: hasPreviousPage.length == 1, + startCursor: firstId + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f59dd4c5d..f037705333 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,9 +565,15 @@ importers: objection: specifier: ^3.1.5 version: 3.1.5(knex@3.1.0) + objection-db-errors: + specifier: ^1.1.2 + version: 1.1.2(objection@3.1.5) pino: specifier: ^8.19.0 version: 8.19.0 + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@types/koa': specifier: 2.15.0 @@ -581,6 +587,9 @@ importers: '@types/koa__router': specifier: ^12.0.4 version: 12.0.4 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 ts-node-dev: specifier: ^2.0.0 version: 2.0.0(@swc/core@1.11.29)(@types/node@20.14.15)(typescript@5.8.3)