diff --git a/.gitignore b/.gitignore index 13dd7a9c..3307d31b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ npm-debug.log* .nyc_output .test-reports .coverage +.history # Dependency directories node_modules/ @@ -30,6 +31,7 @@ dist # IDE - VSCode .vscode/* +!.vscode/settings.json # Env files .env diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ca8e8f1d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "denoland.vscode-deno", + "[typescriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } + } \ No newline at end of file diff --git a/CONFIGURATION.md b/CONFIGURATION.md index fa041308..1fc739b5 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -9,6 +9,7 @@ The following environment variables can be set: | SECRET | Long random secret. | abcdefghijklmnopqrstuvwxyz1234567890 | | RELAY_PORT | Relay's server port | 8008 | | RELAY_PRIVATE_KEY | Relay's private key in hex | (auto-generated) | +| API_KEY | With access to doshboard (dashborad)| | | MONGO_URI | MongoDB URI | | | MONGO_MIN_POOL_SIZE | Min. connections per worker | 0 | | MONGO_MAX_POOL_SIZE | Max. connections per worker | 3 | diff --git a/deno.jsonc b/deno.jsonc index 3d7c7c67..3676aab3 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -57,10 +57,14 @@ "@isaacs/ttlcache": "npm:@isaacs/ttlcache@1.4.0", "mongodb": "npm:mongodb@5.3.0", "mongoose": "npm:mongoose@7.1.1", + "mongoose-paginate": "npm:mongoose-paginate-v2@1.7.1", + "mongoose-aggregate-paginate": "npm:mongoose-aggregate-paginate-v2@1.0.6", "axios": "npm:axios@1.2.6", "tor-control-ts": "npm:tor-control-ts@1.0.0", "js-yaml": "npm:js-yaml@4.1.0", - "bech32": "npm:bech32@2.0.0" + "bech32": "npm:bech32@2.0.0", + "underscore": "npm:underscore@1.13.6", + "dayjs": "npm:dayjs@1.11.8" }, "fmt": { "files": { diff --git a/deno.lock b/deno.lock index 1c461221..b0fec5b5 100644 --- a/deno.lock +++ b/deno.lock @@ -985,9 +985,13 @@ "@isaacs/ttlcache@1.4.0": "@isaacs/ttlcache@1.4.0", "axios@1.2.6": "axios@1.2.6", "bech32@2.0.0": "bech32@2.0.0", + "dayjs@1.11.8": "dayjs@1.11.8", "js-yaml@4.1.0": "js-yaml@4.1.0", "mongodb@5.3.0": "mongodb@5.3.0", - "mongoose@7.1.1": "mongoose@7.1.1" + "mongoose-aggregate-paginate-v2@1.0.6": "mongoose-aggregate-paginate-v2@1.0.6", + "mongoose-paginate-v2@1.7.1": "mongoose-paginate-v2@1.7.1", + "mongoose@7.1.1": "mongoose@7.1.1", + "underscore@1.13.6": "underscore@1.13.6" }, "packages": { "@isaacs/ttlcache@1.4.0": { @@ -1039,6 +1043,10 @@ "delayed-stream": "delayed-stream@1.0.0" } }, + "dayjs@1.11.8": { + "integrity": "sha512-LcgxzFoWMEPO7ggRv1Y2N31hUf2R0Vj7fuy/m+Bg1K8rr+KAs1AEy4y9jd5DXe8pbHgX+srkHNS7TH6Q6ZhYeQ==", + "dependencies": {} + }, "debug@4.3.4": { "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dependencies": { @@ -1105,6 +1113,14 @@ "socks": "socks@2.7.1" } }, + "mongoose-aggregate-paginate-v2@1.0.6": { + "integrity": "sha512-UuALu+mjhQa1K9lMQvjLL3vm3iALvNw8PQNIh2gp1b+tO5hUa0NC0Wf6/8QrT9PSJVTihXaD8hQVy3J4e0jO0Q==", + "dependencies": {} + }, + "mongoose-paginate-v2@1.7.1": { + "integrity": "sha512-J8DJw3zRXcXOKoZv+RvO9tt5HotRnbo2iCR3lke+TtsQsYwQvbY3EgUkPqZXw6qCX2IByvXrW5SGNdAB0od/Cw==", + "dependencies": {} + }, "mongoose@7.1.1": { "integrity": "sha512-AIxaWwGY+td7QOMk4NgK6fbRuGovFyDzv65nU1uj1DsUh3lpjfP3iFYHSR+sUKrs7nbp19ksLlRXkmInBteSCA==", "dependencies": { @@ -1176,6 +1192,10 @@ "punycode": "punycode@2.3.0" } }, + "underscore@1.13.6": { + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dependencies": {} + }, "webidl-conversions@7.0.0": { "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dependencies": {} diff --git a/src/@types/api.ts b/src/@types/api.ts new file mode 100644 index 00000000..c95a66dc --- /dev/null +++ b/src/@types/api.ts @@ -0,0 +1,4 @@ +export interface AmountRow { + _id: string + total: number +} diff --git a/src/@types/invoice.ts b/src/@types/invoice.ts index e4be54a3..ff4b4430 100644 --- a/src/@types/invoice.ts +++ b/src/@types/invoice.ts @@ -1,6 +1,5 @@ import { Buffer } from 'Buffer' import mongoose from 'mongoose' -import { ObjectId } from 'mongodb' import { Pubkey } from './base.ts' @@ -37,8 +36,7 @@ export interface LnurlInvoice extends Invoice { } export interface DBInvoice extends mongoose.Document { - _id: ObjectId - id: string + _id: string pubkey: Buffer bolt11: string amount_requested: bigint diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index e5ceda8d..bf8b53c6 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -23,7 +23,7 @@ export interface IEventRepository { } export interface IInvoiceRepository { - findById(id: string): Promise + findById(invoiceId: string): Promise upsert(invoice: Partial): Promise updateStatus( invoice: Pick, diff --git a/src/controllers/api/events-controller.ts b/src/controllers/api/events-controller.ts new file mode 100644 index 00000000..e53735ff --- /dev/null +++ b/src/controllers/api/events-controller.ts @@ -0,0 +1,26 @@ +import { helpers, IController, Request, Response, RouterContext } from '@/@types/controllers.ts' +import { readReplicaEventsModel } from '@/database/models/Events.ts' +import { Sort } from '@/constants/base.ts' +import { toNostrEvent } from '@/utils/event.ts' + +export class EventsController implements IController { + public async handleRequest(_: Request, response: Response, ctx: RouterContext) { + const query = helpers.getQuery(ctx) + const { sortField = 'event_created_at', sortValue = 'desc' } = query + + const limit = query?.limit ? parseInt(query.limit) : 10 + const page = query?.page ? parseInt(query.page) : 1 + const sort = { [sortField]: Sort.DESC } + if (['asc', 'desc'].includes(sortValue)) { + if (sortValue === 'asc') { + sort[sortField] = Sort.ASC + } + } + + response.body = await readReplicaEventsModel.paginate({ event_kind: 1 }, { sort, limit, page }) + .then((result) => ({ + ...result, + docs: result.docs.map(toNostrEvent), + })) + } +} diff --git a/src/database/DatabaseWatcher.ts b/src/database/DatabaseWatcher.ts index 7ff2a407..b2f8c5e0 100644 --- a/src/database/DatabaseWatcher.ts +++ b/src/database/DatabaseWatcher.ts @@ -29,7 +29,7 @@ export class DatabaseWatcher extends EventEmitter { private metrics?: any - private changeStream: ChangeStream + private changeStream!: ChangeStream /** * Last doc timestamp received from a real time event diff --git a/src/database/models/Events.ts b/src/database/models/Events.ts index 2a803746..e5199a08 100644 --- a/src/database/models/Events.ts +++ b/src/database/models/Events.ts @@ -1,4 +1,6 @@ import mongoose, { FilterQuery } from 'mongoose' +import paginate from 'mongoose-paginate' +import aggregatePaginate from 'mongoose-aggregate-paginate' import { getMasterDbClient, getReadReplicaDbClient } from '@/database/client.ts' import { Buffer } from 'Buffer' @@ -8,7 +10,7 @@ import { isGenericTagQuery } from '@/utils/filter.ts' import { Sort } from '@/constants/base.ts' import { toBuffer } from '@/utils/transform.ts' -const EventSchema = new mongoose.Schema({ +const eventSchema = new mongoose.Schema({ event_id: { type: Buffer, require: true, @@ -49,82 +51,85 @@ const EventSchema = new mongoose.Schema({ expires_at: { type: Number }, }) -EventSchema.index({ 'event_id': 1 }, { +eventSchema.index({ 'event_id': 1 }, { background: true, unique: true, }) -EventSchema.index({ 'event_pubkey': 1 }, { +eventSchema.index({ 'event_pubkey': 1 }, { background: true, }) -EventSchema.index({ 'event_kind': 1 }, { +eventSchema.index({ 'event_kind': 1 }, { background: true, }) -EventSchema.index({ 'event_signature': 1 }, { +eventSchema.index({ 'event_signature': 1 }, { background: true, }) -EventSchema.index({ 'event_created_at': 1 }, { +eventSchema.index({ 'event_created_at': 1 }, { background: true, }) -EventSchema.index({ 'event_tags.0.0': 1 }, { +eventSchema.index({ 'event_tags.0.0': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.0.1': 1 }, { +eventSchema.index({ 'event_tags.0.1': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.0.2': 1 }, { +eventSchema.index({ 'event_tags.0.2': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.0.3': 1 }, { +eventSchema.index({ 'event_tags.0.3': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.1.0': 1 }, { +eventSchema.index({ 'event_tags.1.0': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.1.1': 1 }, { +eventSchema.index({ 'event_tags.1.1': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.1.2': 1 }, { +eventSchema.index({ 'event_tags.1.2': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.1.3': 1 }, { +eventSchema.index({ 'event_tags.1.3': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.2.0': 1 }, { +eventSchema.index({ 'event_tags.2.0': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.2.1': 1 }, { +eventSchema.index({ 'event_tags.2.1': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.2.2': 1 }, { +eventSchema.index({ 'event_tags.2.2': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'event_tags.2.3': 1 }, { +eventSchema.index({ 'event_tags.2.3': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'remote_address': 1 }, { +eventSchema.index({ 'remote_address': 1 }, { background: true, }) -EventSchema.index({ 'expires_at': 1 }, { +eventSchema.index({ 'expires_at': 1 }, { background: true, sparse: true, }) -EventSchema.index({ 'deleted_at': 1 }, { +eventSchema.index({ 'deleted_at': 1 }, { background: true, sparse: true, }) +eventSchema.plugin(paginate) +eventSchema.plugin(aggregatePaginate) + export const buildMongoFilter = ( filters: SubscriptionFilter[], ) => { @@ -215,7 +220,7 @@ export const buildMongoFilter = ( } } -EventSchema.static('findBySubscriptionFilter', function (filters: SubscriptionFilter[], maxLimit: number) { +eventSchema.static('findBySubscriptionFilter', function (filters: SubscriptionFilter[], maxLimit: number) { const query = buildMongoFilter(filters) const defaultLimit = 500 let sort = Sort.ASC @@ -233,19 +238,19 @@ EventSchema.static('findBySubscriptionFilter', function (filters: SubscriptionFi return this.find(query).limit(limit).sort({ event_created_at: sort }) }) -EventSchema.static('countBySubscriptionFilter', function (filters: SubscriptionFilter[]) { +eventSchema.static('countBySubscriptionFilter', function (filters: SubscriptionFilter[]) { const query = buildMongoFilter(filters) return this.countDocuments(query) }) -export const EventsModelName = 'Events' -export const EventsCollectionName = 'events' +export const modelName = 'Events' +export const collectionName = 'events' export const EventsModel = (dbClient: mongoose.Connection) => - dbClient.model( - EventsModelName, - EventSchema, - EventsCollectionName, + dbClient.model>( + modelName, + eventSchema, + collectionName, ) export const masterEventsModel = EventsModel(getMasterDbClient()) diff --git a/src/database/models/Invoices.ts b/src/database/models/Invoices.ts index 71151c3a..03fac077 100644 --- a/src/database/models/Invoices.ts +++ b/src/database/models/Invoices.ts @@ -1,12 +1,12 @@ import mongoose from 'mongoose' +import paginate from 'mongoose-paginate' +import aggregatePaginate from 'mongoose-aggregate-paginate' import { getMasterDbClient, getReadReplicaDbClient } from '@/database/client.ts' import { DBInvoice } from '@/@types/invoice.ts' -const InvoiceSchema = new mongoose.Schema({ - id: { - type: String, - }, +const invoiceSchema = new mongoose.Schema({ + _id: String, pubkey: { type: String, }, @@ -48,57 +48,53 @@ const InvoiceSchema = new mongoose.Schema({ verify_url: { type: String, }, -}, { - id: true, - _id: false, }) -InvoiceSchema.index({ 'id': 1 }, { - unique: true, -}) +invoiceSchema.plugin(paginate) +invoiceSchema.plugin(aggregatePaginate) -InvoiceSchema.index({ 'pubkey': 1 }, { +invoiceSchema.index({ 'pubkey': 1 }, { background: true, }) -InvoiceSchema.index({ 'bolt11': 1 }, { +invoiceSchema.index({ 'bolt11': 1 }, { background: true, }) -InvoiceSchema.index({ 'amount_requested': 1 }, { +invoiceSchema.index({ 'amount_requested': 1 }, { background: true, }) -InvoiceSchema.index({ 'amount_paid': 1 }, { +invoiceSchema.index({ 'amount_paid': 1 }, { background: true, sparse: true, }) -InvoiceSchema.index({ 'unit': 1 }, { +invoiceSchema.index({ 'unit': 1 }, { background: true, }) -InvoiceSchema.index({ 'status': 1 }, { +invoiceSchema.index({ 'status': 1 }, { background: true, }) -InvoiceSchema.index({ 'created_at': 1 }, { +invoiceSchema.index({ 'created_at': 1 }, { background: true, }) -InvoiceSchema.index({ 'confirmed_at': 1 }, { +invoiceSchema.index({ 'confirmed_at': 1 }, { background: true, sparse: true, }) -export const InvoicesModelName = 'Invoices' -export const InvoicesCollectionName = 'invoices' +export const modelName = 'Invoices' +export const collectionName = 'invoices' export const InvoicesModel = (dbClient: mongoose.Connection) => - dbClient.model( - 'Invoices', - InvoiceSchema, - 'invoices', + dbClient.model>( + modelName, + invoiceSchema, + collectionName, ) export const masterInvoicesModel = InvoicesModel(getMasterDbClient()) diff --git a/src/database/models/Users.ts b/src/database/models/Users.ts index f630c235..a25cd8d1 100644 --- a/src/database/models/Users.ts +++ b/src/database/models/Users.ts @@ -1,9 +1,11 @@ import mongoose from 'mongoose' +import paginate from 'mongoose-paginate' +import aggregatePaginate from 'mongoose-aggregate-paginate' import { getMasterDbClient, getReadReplicaDbClient } from '@/database/client.ts' import { DBUser } from '@/@types/user.ts' -const UserSchema = new mongoose.Schema({ +const userSchema = new mongoose.Schema({ pubkey: { type: String, }, @@ -30,28 +32,31 @@ const UserSchema = new mongoose.Schema({ _id: false, }) -UserSchema.index({ 'pubkey': 1 }, { +userSchema.plugin(paginate) +userSchema.plugin(aggregatePaginate) + +userSchema.index({ 'pubkey': 1 }, { background: true, unique: true, }) -UserSchema.index({ 'balance': 1 }, { +userSchema.index({ 'balance': 1 }, { background: true, }) -UserSchema.index({ 'is_admitted': 1 }, { +userSchema.index({ 'is_admitted': 1 }, { background: true, }) -UserSchema.index({ 'created_at': 1 }, { +userSchema.index({ 'created_at': 1 }, { background: true, }) -export const UsersModelName = 'Users' -export const UsersCollectionName = 'users' +export const modelName = 'Users' +export const collectionName = 'users' export const UsersModel = (dbClient: mongoose.Connection) => - dbClient.model( - UsersModelName, - UserSchema, - UsersCollectionName, + dbClient.model>( + modelName, + userSchema, + collectionName, ) export const masterUsersModel = UsersModel(getMasterDbClient()) diff --git a/src/database/watchCollections.ts b/src/database/watchCollections.ts index 5b6fd7c1..c54ed94a 100644 --- a/src/database/watchCollections.ts +++ b/src/database/watchCollections.ts @@ -1,5 +1,5 @@ -import { EventsCollectionName } from './models/Events.ts' +import { collectionName as eventsCollection } from './models/Events.ts' export default [ - EventsCollectionName, + eventsCollection, ] diff --git a/src/database/watchers.ts b/src/database/watchers.ts index e1adb0bd..669fb36e 100644 --- a/src/database/watchers.ts +++ b/src/database/watchers.ts @@ -3,9 +3,9 @@ import mongoose from 'mongoose' import type { EventSignatures } from '../core-services/index.ts' import { DatabaseWatcher } from './DatabaseWatcher.ts' -import { EventsCollectionName } from './models/Events.ts' import type { DBEvent } from '../@types/event.ts' import { createLogger } from '../factories/logger-factory.ts' +import { collectionName as eventsCollection } from './models/Events.ts' export type Watcher = ( model: mongoose.Model, @@ -25,7 +25,7 @@ export function initWatchers( watcher: DatabaseWatcher, broadcast: BroadcastCallback, ): void { - watcher.on(EventsCollectionName, (event) => { + watcher.on(eventsCollection, (event) => { debug('events %o', event) const { clientAction, data, diff, id } = event diff --git a/src/factories/controllers/api-controller-factory.ts b/src/factories/controllers/api-controller-factory.ts new file mode 100644 index 00000000..810d4a46 --- /dev/null +++ b/src/factories/controllers/api-controller-factory.ts @@ -0,0 +1,6 @@ +import { EventsController } from '@/controllers/api/events-controller.ts' +import { IController } from '@/@types/controllers.ts' + +export const createEventsController = (): IController => { + return new EventsController() +} diff --git a/src/handlers/request-handlers/verify-apikey-middleware.ts b/src/handlers/request-handlers/verify-apikey-middleware.ts new file mode 100644 index 00000000..027e0cd6 --- /dev/null +++ b/src/handlers/request-handlers/verify-apikey-middleware.ts @@ -0,0 +1,16 @@ +import { NextFunction, RouterContext, Status } from '@/@types/controllers.ts' + +export const verifyApikeyMiddleware = async ( + ctx: RouterContext, + next: NextFunction, +) => { + if (ctx.request.headers.has('x-api-key')) { + const apiKey = Deno.env.get('API_KEY') || '' + if (apiKey && ctx.request.headers.get('x-api-key') === apiKey) { + return await next() + } + } + + ctx.response.status = Status.BadRequest + ctx.response.body = 'Invalid API KEY' +} diff --git a/src/repositories/invoice-repository.ts b/src/repositories/invoice-repository.ts index 6dd4787c..2770baed 100644 --- a/src/repositories/invoice-repository.ts +++ b/src/repositories/invoice-repository.ts @@ -25,12 +25,12 @@ export class InvoiceRepository implements IInvoiceRepository { ) try { - const invoice = await masterInvoicesModel.findOne({ id: invoiceId }) + const invoice = await masterInvoicesModel.findOne({ _id: invoiceId }) if (invoice) { const options = { ...(session && { session }) } await masterInvoicesModel.updateOne( - { id: invoiceId }, + { _id: invoiceId }, { $set: { confirmed_at: confirmedAt, @@ -66,9 +66,9 @@ export class InvoiceRepository implements IInvoiceRepository { } public async findById( - id: string, + invoiceId: string, ): Promise { - const dbInvoice = await masterInvoicesModel.findOne({ id }) + const dbInvoice = await masterInvoicesModel.findOne({ _id: invoiceId }) if (!dbInvoice) { return @@ -81,12 +81,9 @@ export class InvoiceRepository implements IInvoiceRepository { offset = 0, limit = 10, ): Promise { - const dbInvoices = await masterInvoicesModel - .find({ status: InvoiceStatus.PENDING }) - .skip(offset) - .limit(limit) + const dbInvoices = await masterInvoicesModel.paginate({ status: InvoiceStatus.PENDING }, { offset, limit }) - return dbInvoices.map(fromDBInvoice) + return dbInvoices.docs.map(fromDBInvoice) } public updateStatus( @@ -97,31 +94,13 @@ export class InvoiceRepository implements IInvoiceRepository { const options: any = { ...(session && { session }) } const query = masterInvoicesModel.updateOne({ - id: invoice.id, + _id: invoice.id, }, { status: invoice.status, updated_at: new Date(), }, options) return ignoreUpdateConflicts(query) - - // const query = client('invoices') - // .update({ - // status: invoice.status, - // updated_at: new Date(), - // }) - // .where('id', invoice.id) - // .limit(1) - // .returning(['*']) - - // return { - // then: ( - // onfulfilled: (value: Invoice | undefined) => T1 | PromiseLike, - // onrejected: (reason: any) => T2 | PromiseLike, - // ) => query.then(pipe(map(fromDBInvoice), head)).then(onfulfilled, onrejected), - // catch: (onrejected: (reason: any) => T | PromiseLike) => query.catch(onrejected), - // toString: (): string => query.toString(), - // } as Promise } public upsert( @@ -130,12 +109,11 @@ export class InvoiceRepository implements IInvoiceRepository { debug('upserting invoice: %o', invoice) const row: DBInvoice = applySpec({ - id: ifElse( + _id: ifElse( propSatisfies(is(String), 'id'), prop('id'), always(crypto.randomUUID()), ), - // pubkey: pipe(prop('pubkey'), toBuffer), pubkey: prop('pubkey'), bolt11: prop('bolt11'), amount_requested: pipe(prop('amountRequested'), toString), @@ -152,38 +130,9 @@ export class InvoiceRepository implements IInvoiceRepository { debug('row: %o', row) - const query = masterInvoicesModel.updateOne({ id: row.id }, { $set: row }, { upsert: true }) + const query = masterInvoicesModel.updateOne({ _id: row._id }, { $set: row }, { upsert: true }) return ignoreUpdateConflicts(query) - - // const query = client('invoices') - // .insert(row) - // .onConflict('id') - // .merge( - // omit([ - // 'id', - // 'pubkey', - // 'bolt11', - // 'amount_requested', - // 'unit', - // 'description', - // 'expires_at', - // 'created_at', - // 'verify_url', - // ])(row), - // ) - - // return { - // then: ( - // onfulfilled: (value: number) => T1 | PromiseLike, - // onrejected: (reason: any) => T2 | PromiseLike, - // ) => query.then(prop('rowCount') as () => number).then( - // onfulfilled, - // onrejected, - // ), - // catch: (onrejected: (reason: any) => T | PromiseLike) => query.catch(onrejected), - // toString: (): string => query.toString(), - // } as Promise } } diff --git a/src/routes/api/events.ts b/src/routes/api/events.ts new file mode 100644 index 00000000..f973a8f1 --- /dev/null +++ b/src/routes/api/events.ts @@ -0,0 +1,11 @@ +import { Router } from 'oak' + +import { withController } from '@/handlers/request-handlers/with-controller-request-handler.ts' +import { createEventsController } from '@/factories/controllers/api-controller-factory.ts' + +const eventsRouter = new Router() + +eventsRouter + .get('/', withController(createEventsController)) + +export default eventsRouter diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts new file mode 100644 index 00000000..90502043 --- /dev/null +++ b/src/routes/api/index.ts @@ -0,0 +1,12 @@ +import { Router } from 'oak' + +import metrics from '@/routes/api/metrics.ts' +import events from '@/routes/api/events.ts' + +const router = new Router() + +router.use('/metrics', metrics.routes(), metrics.allowedMethods()) + +router.use('/events', events.routes(), events.allowedMethods()) + +export default router diff --git a/src/routes/api/metrics.ts b/src/routes/api/metrics.ts new file mode 100644 index 00000000..b642e45a --- /dev/null +++ b/src/routes/api/metrics.ts @@ -0,0 +1,209 @@ +// deno-lint-ignore-file no-inferrable-types +import _ from 'underscore' +import dayjs from 'dayjs' + +import { Context, Router } from '@/@types/controllers.ts' +import { readReplicaEventsModel } from '@/database/models/Events.ts' +import { createSettings } from '@/factories/settings-factory.ts' +import { SettingsStatic } from '@/utils/settings.ts' +import { Settings } from '@/@types/settings.ts' +import { DBEvent, Event } from '@/@types/event.ts' +import { toNostrEvent } from '@/utils/event.ts' +import { readReplicaInvoicesModel } from '@/database/models/Invoices.ts' +import { AmountRow } from '@/@types/api.ts' + +const router = new Router() + +router.get('/events', async (ctx: Context) => { + const response = ctx.response + // const req = ctx.request + + const unixTimeNow = Math.floor(Date.now() / 1000) + const query = { + event_created_at: { + $lte: unixTimeNow, + }, + } + + const dbEvents: DBEvent[] = await readReplicaEventsModel.find(query) + const events = dbEvents + .map((event) => toNostrEvent(event)) + .filter((event) => event.created_at < unixTimeNow && event.content !== '') + + const uniqueEvents: Event[] = _.uniq(events, (event) => event.id) + const events24Hours: Event[] = events.filter((e) => e.created_at > (unixTimeNow - 60 * 60 * 24)) + const latestEvents: Event[] = _.sortBy(uniqueEvents, 'created_at').reverse().slice(0, 30) // 30 is longListAmount + const kindsList: { [kind: string]: number } = _.countBy(events, 'kind') + const uniquePubkeys: Event[] = _.uniq(events, (event) => event.pubkey) + const uniquePubkeys24Hours: Event[] = uniquePubkeys.filter((e) => e.created_at > (unixTimeNow - 60 * 60 * 24)) + + const eventsUTC: number[] = events.map((event) => { + const eventDate = new Date(event.created_at * 1000) + return eventDate.getUTCHours() + }) + + // key: utc, value: the count of event in that time + const UTCList: { [utc: string]: number } = _.countBy(eventsUTC) + + response.type = 'json' + response.body = { + status: 'ok', + + utc: UTCList, + uniquePubkeys: uniquePubkeys.length, + uniquePubkeys24Hours: uniquePubkeys24Hours.length, + kinds: kindsList, + // relays: relayCount, + eventCount: events.length, + eventCount24Hours: events24Hours.length, + events: latestEvents, + // where: whereArray, + } +}) + +router.get('/order/amount', async (ctx: Context) => { + const response = ctx.response + const pipline = [ + { + $match: { status: 'completed' }, + }, + { + $group: { + _id: '$unit', + total: { + $sum: '$amount_paid', + }, + }, + }, + ] + + const amountArr: AmountRow[] = await readReplicaInvoicesModel.aggregate(pipline) + + let amount: number = 0 + for (let i = 0; i < amountArr.length; i++) { + if (amountArr[i]._id == 'msats') { + amount += amountArr[i].total / 1000 + } + if (amountArr[i]._id == 'sats') { + amount += amountArr[i].total + } + } + + response.type = 'json' + response.body = { + status: 'ok', + total: amount, + } +}) + +router.get('/events/monthly', async (ctx: Context) => { + const response = ctx.response + // const req = ctx.request + + const unixTime = Date.now() / 1000 + const now = new Date() + now.setMonth(-1) + const unixTimeBeforeMonth = now.valueOf() / 1000 + const theMonLastDay = dayjs(now).endOf('month').get('D') + + const query = { + event_created_at: { + $lte: Math.floor(unixTime), + $gte: Math.floor(unixTimeBeforeMonth), + }, + } + + const dbEvents: DBEvent[] = await readReplicaEventsModel.find(query) + const events = dbEvents + .map((event) => toNostrEvent(event)) + .filter((event) => event.created_at < unixTime && event.created_at > unixTimeBeforeMonth) + + const currentTimestamp = Math.floor(unixTime) + + // events.push({ ...events[0], created_at: Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 3 }) + for (let i = 0; i < theMonLastDay; i++) { + const fromTimestamp = Math.floor(currentTimestamp - 60 * 60 * 24 * (i + 1)) + const toTimestamp = Math.floor(currentTimestamp - 60 * 60 * 24 * i) + + // if timestamp is in a range of a particular day, + // squash it to the timestamp at the start of the day + for (let j = 0; j < events.length; j++) { + if (events[j].created_at > fromTimestamp && events[j].created_at < toTimestamp) { + events[j].created_at = fromTimestamp + } + } + } + + const monthlyData = _.countBy(events, 'created_at') + + response.type = 'json' + response.body = { + status: 'ok', + body: monthlyData, + } +}) + +router.get('/events/yearly', async (ctx: Context) => { + const response = ctx.response + // const req = ctx.request + + const unixTime = Date.now() / 1000 + const now = new Date() + now.setFullYear(-1) + + const unixTimeMinux1yr = now.valueOf() / 1000 + + const query = { + event_created_at: { + $lte: Math.floor(unixTime), + $gte: Math.floor(unixTimeMinux1yr), + }, + } + + const dbEvents: DBEvent[] = await readReplicaEventsModel.find(query) + const events = dbEvents + .map((event) => toNostrEvent(event)) + .filter((event) => event.created_at < unixTime && event.created_at > unixTimeMinux1yr) + + for (let i = 0; i < 12; i++) { + const now = dayjs.month(i) + const currentTimestamp = Math.floor(now.valueOf() / 1000) + const fromTimestamp = Math.floor(currentTimestamp - 60 * 60 * 24 * now.daysInMonth() * (i + 1)) + const toTimestamp = Math.floor(currentTimestamp - 60 * 60 * 24 * now.daysInMonth() * i) + + // if timestamp is in a range of a particular day, + // squash it to the timestamp at the start of the day + for (let j = 0; j < events.length; j++) { + if (events[j].created_at > fromTimestamp && events[j].created_at < toTimestamp) { + events[j].created_at = fromTimestamp + } + } + } + + const yearlyData = _.countBy(events, 'created_at') + response.type = 'json' + response.body = { + status: 'ok', + body: yearlyData, + } +}) +router.get('/settings', (ctx: Context) => { + ctx.response.type = 'json' + ctx.response.body = createSettings() +}) + .post('/settings', async (ctx: Context) => { + const response = ctx.response + const req = ctx.request + + const parseBody = req.body({ type: 'json' }) + const body = await parseBody.value + const basePath = SettingsStatic.getSettingsFileBasePath() + SettingsStatic.saveSettings(basePath, body as Settings) + + response.type = 'json' + response.body = { + ok: 1, + } + }) + +export default router diff --git a/src/routes/index.ts b/src/routes/index.ts index f453ffbf..c6b0b406 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,9 +3,11 @@ import { Router } from 'oak' import { getHealthRequestHandler } from '@/handlers/request-handlers/get-health-request-handler.ts' import { getTermsRequestHandler } from '@/handlers/request-handlers/get-terms-request-handler.ts' import { rateLimiterMiddleware } from '@/handlers/request-handlers/rate-limiter-middleware.ts' +import { verifyApikeyMiddleware } from '@/handlers/request-handlers/verify-apikey-middleware.ts' import { rootRequestHandler } from '@/handlers/request-handlers/root-request-handler.ts' import callbacksRouter from '@/routes/callbacks/index.ts' import invoiceRouter from '@/routes/invoices/index.ts' +import apiRouter from '@/routes/api/index.ts' const router = new Router() @@ -44,4 +46,11 @@ router.use( callbacksRouter.allowedMethods(), ) +router.use( + '/api', + verifyApikeyMiddleware, + apiRouter.routes(), + apiRouter.allowedMethods(), +) + export default router diff --git a/src/utils/transform.ts b/src/utils/transform.ts index f530d01c..da939490 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -17,7 +17,7 @@ export const fromBigInt = (input: bigint) => input.toString() const addTime = (ms: number) => (input: Date) => new Date(input.getTime() + ms) export const fromDBInvoice = applySpec({ - id: prop('id') as () => string, + id: prop('_id') as () => string, pubkey: prop('pubkey'), bolt11: prop('bolt11'), amountRequested: pipe(prop('amount_requested') as () => string, toBigInt),