Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const fields = [
{
key: 'contactEmail',
label: 'Contact email',
description: 'We use this to send onboarding confirmation.',
type: 'email',
required: true,
placeholder: 'me@interledger.org',
order: 2,
format: 'email',
maxLength: 255
},
{
id: '1acf7723-e1cd-44e7-a5db-3f614ce045ac',
key: 'merchantCategoryCode',
label: 'Merchant category',
type: 'select',
required: true,
order: 1
}
]

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
await knex.schema.createTable('field_definitions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'))

table.string('key').notNullable()
table.string('label').notNullable()
table.string('description').nullable()
table
.enum('type', [
'text',
'email',
'tel',
'number',
'select',
'checkbox',
'date'
])
.notNullable()
table.boolean('required').notNullable().defaultTo(false)
table.string('placeholder').nullable()
table.integer('order').notNullable()

table.integer('maxLength').nullable()
table.string('format').nullable()

table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
})

return knex('field_definitions').insert(fields)
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('field_definitions')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const options = [
{
fieldId: '1acf7723-e1cd-44e7-a5db-3f614ce045ac',
value: '5411',
label: 'Grocery stores / supermarkets'
},
{
fieldId: '1acf7723-e1cd-44e7-a5db-3f614ce045ac',
value: '5812',
label: 'Eating places / restaurants'
},
{
fieldId: '1acf7723-e1cd-44e7-a5db-3f614ce045ac',
value: '5999',
label: 'Miscellaneous retail'
}
]
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
await knex.schema.createTable('options', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'))

table.uuid('fieldId').notNullable()
table.foreign('fieldId').references('id').inTable('field_definitions')
table.string('value').notNullable()
table.string('label').notNullable()

table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
})

return knex('options').insert(options)
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('options')
}
9 changes: 9 additions & 0 deletions packages/wallet/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { GateHubClient } from '@/gatehub/client'
import { GateHubService } from '@/gatehub/service'
import { CardController } from './card/controller'
import { CardService } from './card/service'
import { TerminalController } from './terminal/controller'
import { isRafikiSignedWebhook } from '@/middleware/isRafikiSignedWebhook'
import { isGateHubSignedWebhook } from '@/middleware/isGateHubSignedWebhook'

Expand Down Expand Up @@ -89,6 +90,7 @@ export interface Bindings {
gateHubService: GateHubService
cardService: CardService
cardController: CardController
terminalController: TerminalController
}

export class App {
Expand Down Expand Up @@ -164,6 +166,7 @@ export class App {
const interledgerCardController = this.container.resolve(
'interledgerCardController'
)
const terminalController = this.container.resolve('terminalController')

app.use(
cors({
Expand Down Expand Up @@ -454,6 +457,12 @@ export class App {
interledgerCardController.terminate
)

// Terminal
router.get(
'/terminals/onboarding',
terminalController.getOnboardingFormDefinition
)

// Return an error for invalid routes
router.use('*', (req: Request, res: CustomResponse) => {
const e = Error(`Requested path ${req.path} was not found`)
Expand Down
8 changes: 7 additions & 1 deletion packages/wallet/backend/src/createContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import { StripeController } from './stripe-integration/controller'
import { StripeService } from './stripe-integration/service'
import { InterledgerCardController } from '@/interledgerCard/controller'
import { InterledgerCardService } from '@/interledgerCard/service'
import { TerminalController } from '@/terminal/controller'
import { TerminalService } from '@/terminal/service'

export interface Cradle {
env: Env
Expand Down Expand Up @@ -104,6 +106,8 @@ export interface Cradle {
cardController: CardController
interledgerCardService: InterledgerCardService
interledgerCardController: InterledgerCardController
terminalService: TerminalService
terminalController: TerminalController
}

export async function createContainer(
Expand Down Expand Up @@ -166,7 +170,9 @@ export async function createContainer(
gateHubService: asClass(GateHubService).singleton(),
cardController: asClass(CardController).singleton(),
interledgerCardService: asClass(InterledgerCardService).singleton(),
interledgerCardController: asClass(InterledgerCardController).singleton()
interledgerCardController: asClass(InterledgerCardController).singleton(),
terminalService: asClassSingletonWithLogger(TerminalService, logger),
terminalController: asClass(TerminalController).singleton()
})

return container
Expand Down
20 changes: 20 additions & 0 deletions packages/wallet/backend/src/terminal/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Request, Response, NextFunction } from 'express'
import { TerminalService } from './service'

export class TerminalController {
constructor(private terminalService: TerminalService) {}

getOnboardingFormDefinition = async (
_req: Request,
res: Response,
next: NextFunction
) => {
try {
const formDefinition =
await this.terminalService.getOnboardingFormDefinition()
res.status(200).json(formDefinition)
} catch (error) {
next(error)
}
}
}
57 changes: 57 additions & 0 deletions packages/wallet/backend/src/terminal/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { BaseModel } from '@shared/backend'
import { Model } from 'objection'

interface Validation {
minLength?: number
maxLength?: number
pattern?: string
min?: number
max?: number
format?: string
mustEqual?: boolean
}

enum FieldType {
TEXT = 'text',
EMAIL = 'email',
TEL = 'tel',
NUMBER = 'number',
SELECT = 'select',
CHECKBOX = 'checkbox',
DATE = 'date'
}

export class FieldDefinitions extends BaseModel {
static tableName = 'field_definitions'

public key!: string
public label!: string
public description?: string
public type!: FieldType
public required!: boolean
public placeholder?: string
public order!: number
public options?: Options[]
public validation?: Validation
public format?: string
public maxLength?: number

static relationMappings = () => ({
options: {
relation: Model.HasManyRelation,
modelClass: Options,
join: {
from: 'field_definitions.id',
to: 'options.fieldId'
}
}
})
}

export class Options extends BaseModel {
static tableName = 'options'

public fieldId?: string
public value!: string
public label!: string
}
47 changes: 47 additions & 0 deletions packages/wallet/backend/src/terminal/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Logger } from 'winston'
import { FieldDefinitions } from './model'

export class TerminalService {
constructor(private logger: Logger) {}

async getOnboardingFormDefinition(): Promise<FieldDefinitions[]> {
const fields = await FieldDefinitions.query()
.withGraphFetched('options')
.orderBy('order', 'asc')

this.logger.debug('Returning merchant onboarding form definition', {
fields: fields
})

return fields.map((field) => {
const mapped = {
key: field.key,
label: field.label,
type: field.type,
required: field.required,
order: field.order
} as Partial<FieldDefinitions>

if (field.description) mapped.description = field.description
if (field.placeholder) mapped.placeholder = field.placeholder
if (field.format)
mapped.validation = {
...(mapped.validation || {}),
format: field.format
}
if (field.maxLength)
mapped.validation = {
...(mapped.validation || {}),
maxLength: field.maxLength
}
if (field.options?.length) {
mapped.options = field.options.map((opt) => ({
...(opt.value && { value: opt.value }),
...(opt.label && { label: opt.label })
})) as FieldDefinitions['options']
}

return mapped as FieldDefinitions
})
}
}
Loading