From c18f71c5482d6f2cc22e18c35819cfe9afa92603 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 23 Jul 2025 15:45:42 +0000 Subject: [PATCH] store and use notifications token --- Umbrel/umbrel-app.yml | 22 ++-- datasource.js | 6 +- proto/autogenerated/client.md | 16 +++ proto/autogenerated/go/http_client.go | 25 +++++ proto/autogenerated/go/types.go | 4 + proto/autogenerated/ts/express_server.ts | 34 ++++++ proto/autogenerated/ts/http_client.ts | 11 ++ proto/autogenerated/ts/nostr_client.ts | 12 ++ proto/autogenerated/ts/nostr_transport.ts | 28 +++++ proto/autogenerated/ts/types.ts | 31 +++++- proto/service/methods.proto | 6 + proto/service/structs.proto | 5 +- .../ShockPush/autogenerated/http_client.ts | 51 +++++++++ src/services/ShockPush/autogenerated/types.ts | 104 ++++++++++++++++++ src/services/ShockPush/index.ts | 61 ++++++++++ src/services/main/appUserManager.ts | 6 + src/services/main/applicationManager.ts | 2 + src/services/main/index.ts | 26 +++-- src/services/main/notificationsManager.ts | 31 ++++++ src/services/main/settings.ts | 4 +- src/services/serverMethods/index.ts | 8 ++ src/services/storage/applicationStorage.ts | 20 ++++ src/services/storage/db/db.ts | 4 +- src/services/storage/entity/AppUserDevice.ts | 22 ++++ .../1753285173175-app_user_device.ts | 13 +++ src/services/storage/migrations/runner.ts | 4 +- src/services/storage/paymentStorage.ts | 36 +++++- 27 files changed, 564 insertions(+), 28 deletions(-) create mode 100644 src/services/ShockPush/autogenerated/http_client.ts create mode 100644 src/services/ShockPush/autogenerated/types.ts create mode 100644 src/services/ShockPush/index.ts create mode 100644 src/services/main/notificationsManager.ts create mode 100644 src/services/storage/entity/AppUserDevice.ts create mode 100644 src/services/storage/migrations/1753285173175-app_user_device.ts diff --git a/Umbrel/umbrel-app.yml b/Umbrel/umbrel-app.yml index 630c8b5d..8946229a 100644 --- a/Umbrel/umbrel-app.yml +++ b/Umbrel/umbrel-app.yml @@ -1,23 +1,23 @@ - manifestVersion: 1 +manifestVersion: 1 id: lightning-pub category: finance name: Lightning.Pub version: "1.0.0" tagline: lightning, nostr, accounts, lnurl, web description: >- -"Pub" is a Nostr-native account system designed -to make running Lightning infrastructure for your friends/family/customers -easier than previously thought possible. + "Pub" is a Nostr-native account system designed + to make running Lightning infrastructure for your friends/family/customers + easier than previously thought possible. - Being Nostr-native eliminates the complexity of configuring your node like a server by using commodity Nostr relays. -These relays, unlike LNURL proxies, are trustless by nature of Nostr's own encryption spec (NIP44). + Being Nostr-native eliminates the complexity of configuring your node like a server by using commodity Nostr relays. + These relays, unlike LNURL proxies, are trustless by nature of Nostr's own encryption spec (NIP44). - Support for optional services are integrated into Pub for operators seeking backward compatibility with legacy LNURLs and Lightning Addresses. + Support for optional services are integrated into Pub for operators seeking backward compatibility with legacy LNURLs and Lightning Addresses. - By solving the networking and programability hurdles, Pub provides Lightning with a 3rd Layer that enables node-runners and - Uncle Jims to more easily bring their personal network into Bitcoin's permissionless economy. In doing so, Pub runners - can keep the Lightning Network decentralized, with custodial scaling that is free of fiat rails, large banks, - and other forms of high-time-preference shitcoinery. + By solving the networking and programability hurdles, Pub provides Lightning with a 3rd Layer that enables node-runners and + Uncle Jims to more easily bring their personal network into Bitcoin's permissionless economy. In doing so, Pub runners + can keep the Lightning Network decentralized, with custodial scaling that is free of fiat rails, large banks, + and other forms of high-time-preference shitcoinery. developer: shocknet website: https://shock.network dependencies: diff --git a/datasource.js b/datasource.js index 08db3ca7..2f7c5c87 100644 --- a/datasource.js +++ b/datasource.js @@ -18,6 +18,7 @@ import { InviteToken } from "./build/src/services/storage/entity/InviteToken.js" import { DebitAccess } from "./build/src/services/storage/entity/DebitAccess.js" import { UserOffer } from "./build/src/services/storage/entity/UserOffer.js" import { ManagementGrant } from "./build/src/services/storage/entity/ManagementGrant.js" +import { AppUserDevice } from "./build/src/services/storage/entity/AppUserDevice.js" import { Initial1703170309875 } from './build/src/services/storage/migrations/1703170309875-initial.js' import { LspOrder1718387847693 } from './build/src/services/storage/migrations/1718387847693-lsp_order.js' @@ -40,7 +41,8 @@ export default new DataSource({ PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, InvoiceCallbackUrls1752425992291], entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment, - UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo, TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant], + UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo, + TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice], // synchronize: true, }) -//npx typeorm migration:generate ./src/services/storage/migrations/management_grant_banned -d ./datasource.js \ No newline at end of file +//npx typeorm migration:generate ./src/services/storage/migrations/app_user_device -d ./datasource.js \ No newline at end of file diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index fff56015..5b112015 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -93,6 +93,11 @@ The nostr server will send back a message response, and inside the body there wi - input: [EnrollAdminTokenRequest](#EnrollAdminTokenRequest) - This methods has an __empty__ __response__ body +- EnrollMessagingToken + - auth type: __User__ + - input: [MessagingToken](#MessagingToken) + - This methods has an __empty__ __response__ body + - GetAppsMetrics - auth type: __Metrics__ - input: [AppsMetricsRequest](#AppsMetricsRequest) @@ -515,6 +520,13 @@ The nostr server will send back a message response, and inside the body there wi - input: [EnrollAdminTokenRequest](#EnrollAdminTokenRequest) - This methods has an __empty__ __response__ body +- EnrollMessagingToken + - auth type: __User__ + - http method: __post__ + - http route: __/api/user/messaging/enroll__ + - input: [MessagingToken](#MessagingToken) + - This methods has an __empty__ __response__ body + - GetApp - auth type: __App__ - http method: __post__ @@ -1360,6 +1372,10 @@ The nostr server will send back a message response, and inside the body there wi ### ManageOperation - __npub__: _string_ +### MessagingToken + - __device_id__: _string_ + - __firebase_messaging_token__: _string_ + ### MetricsFile ### MigrationUpdate diff --git a/proto/autogenerated/go/http_client.go b/proto/autogenerated/go/http_client.go index 7e64e7c8..e882fcff 100644 --- a/proto/autogenerated/go/http_client.go +++ b/proto/autogenerated/go/http_client.go @@ -74,6 +74,7 @@ type Client struct { EditDebit func(req DebitAuthorizationRequest) error EncryptionExchange func(req EncryptionExchangeRequest) error EnrollAdminToken func(req EnrollAdminTokenRequest) error + EnrollMessagingToken func(req MessagingToken) error GetApp func() (*Application, error) GetAppUser func(req GetAppUserRequest) (*AppUser, error) GetAppUserLNURLInfo func(req GetAppUserLNURLInfoRequest) (*LnurlPayInfoResponse, error) @@ -667,6 +668,30 @@ func NewClient(params ClientParams) *Client { } return nil }, + EnrollMessagingToken: func(req MessagingToken) error { + auth, err := params.RetrieveUserAuth() + if err != nil { + return err + } + finalRoute := "/api/user/messaging/enroll" + body, err := json.Marshal(req) + if err != nil { + return err + } + resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) + if err != nil { + return err + } + result := ResultError{} + err = json.Unmarshal(resBody, &result) + if err != nil { + return err + } + if result.Status == "ERROR" { + return fmt.Errorf(result.Reason) + } + return nil + }, GetApp: func() (*Application, error) { auth, err := params.RetrieveAppAuth() if err != nil { diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index 7e8daa6d..81c77c30 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -445,6 +445,10 @@ type ManageAuthorizations struct { type ManageOperation struct { Npub string `json:"npub"` } +type MessagingToken struct { + Device_id string `json:"device_id"` + Firebase_messaging_token string `json:"firebase_messaging_token"` +} type MetricsFile struct { } type MigrationUpdate struct { diff --git a/proto/autogenerated/ts/express_server.ts b/proto/autogenerated/ts/express_server.ts index 26ac2294..bc29feef 100644 --- a/proto/autogenerated/ts/express_server.ts +++ b/proto/autogenerated/ts/express_server.ts @@ -427,6 +427,18 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) } break + case 'EnrollMessagingToken': + if (!methods.EnrollMessagingToken) { + throw new Error('method EnrollMessagingToken not found' ) + } else { + const error = Types.MessagingTokenValidate(operation.req) + opStats.validate = process.hrtime.bigint() + if (error !== null) throw error + await methods.EnrollMessagingToken({...operation, ctx}); responses.push({ status: 'OK' }) + opStats.handle = process.hrtime.bigint() + callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) + } + break case 'GetDebitAuthorizations': if (!methods.GetDebitAuthorizations) { throw new Error('method GetDebitAuthorizations not found' ) @@ -847,6 +859,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { opts.metricsCallback([{ ...info, ...stats, ...authContext }]) } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }) + if (!opts.allowNotImplementedMethods && !methods.EnrollMessagingToken) throw new Error('method: EnrollMessagingToken is not implemented') + app.post('/api/user/messaging/enroll', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'EnrollMessagingToken', batch: false, nostr: false, batchSize: 0} + const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } + let authCtx: Types.AuthContext = {} + try { + if (!methods.EnrollMessagingToken) throw new Error('method: EnrollMessagingToken is not implemented') + const authContext = await opts.UserAuthGuard(req.headers['authorization']) + authCtx = authContext + stats.guard = process.hrtime.bigint() + const request = req.body + const error = Types.MessagingTokenValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback) + const query = req.query + const params = req.params + await methods.EnrollMessagingToken({rpcName:'EnrollMessagingToken', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res.json({status: 'OK'}) + opts.metricsCallback([{ ...info, ...stats, ...authContext }]) + } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } + }) if (!opts.allowNotImplementedMethods && !methods.GetApp) throw new Error('method: GetApp is not implemented') app.post('/api/app/get', async (req, res) => { const info: Types.RequestInfo = { rpcName: 'GetApp', batch: false, nostr: false, batchSize: 0} diff --git a/proto/autogenerated/ts/http_client.ts b/proto/autogenerated/ts/http_client.ts index 038d91ad..574cce33 100644 --- a/proto/autogenerated/ts/http_client.ts +++ b/proto/autogenerated/ts/http_client.ts @@ -276,6 +276,17 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, + EnrollMessagingToken: async (request: Types.MessagingToken): Promise => { + const auth = await params.retrieveUserAuth() + if (auth === null) throw new Error('retrieveUserAuth() returned null') + let finalRoute = '/api/user/messaging/enroll' + const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + return data + } + return { status: 'ERROR', reason: 'invalid response' } + }, GetApp: async (): Promise => { const auth = await params.retrieveAppAuth() if (auth === null) throw new Error('retrieveAppAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_client.ts b/proto/autogenerated/ts/nostr_client.ts index f06e4eee..cb0e72fe 100644 --- a/proto/autogenerated/ts/nostr_client.ts +++ b/proto/autogenerated/ts/nostr_client.ts @@ -233,6 +233,18 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, + EnrollMessagingToken: async (request: Types.MessagingToken): Promise => { + const auth = await params.retrieveNostrUserAuth() + if (auth === null) throw new Error('retrieveNostrUserAuth() returned null') + const nostrRequest: NostrRequest = {} + nostrRequest.body = request + const data = await send(params.pubDestination, {rpcName:'EnrollMessagingToken',authIdentifier:auth, ...nostrRequest }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + return data + } + return { status: 'ERROR', reason: 'invalid response' } + }, GetAppsMetrics: async (request: Types.AppsMetricsRequest): Promise => { const auth = await params.retrieveNostrMetricsAuth() if (auth === null) throw new Error('retrieveNostrMetricsAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_transport.ts b/proto/autogenerated/ts/nostr_transport.ts index dc244600..add0fde3 100644 --- a/proto/autogenerated/ts/nostr_transport.ts +++ b/proto/autogenerated/ts/nostr_transport.ts @@ -303,6 +303,18 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => { callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) } break + case 'EnrollMessagingToken': + if (!methods.EnrollMessagingToken) { + throw new Error('method not defined: EnrollMessagingToken') + } else { + const error = Types.MessagingTokenValidate(operation.req) + opStats.validate = process.hrtime.bigint() + if (error !== null) throw error + await methods.EnrollMessagingToken({...operation, ctx}); responses.push({ status: 'OK' }) + opStats.handle = process.hrtime.bigint() + callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) + } + break case 'GetDebitAuthorizations': if (!methods.GetDebitAuthorizations) { throw new Error('method not defined: GetDebitAuthorizations') @@ -665,6 +677,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => { opts.metricsCallback([{ ...info, ...stats, ...authContext }]) }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } break + case 'EnrollMessagingToken': + try { + if (!methods.EnrollMessagingToken) throw new Error('method: EnrollMessagingToken is not implemented') + const authContext = await opts.NostrUserAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + const request = req.body + const error = Types.MessagingTokenValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback) + await methods.EnrollMessagingToken({rpcName:'EnrollMessagingToken', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res({status: 'OK'}) + opts.metricsCallback([{ ...info, ...stats, ...authContext }]) + }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } + break case 'GetAppsMetrics': try { if (!methods.GetAppsMetrics) throw new Error('method: GetAppsMetrics is not implemented') diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index cb3f357f..a62d7da7 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -35,8 +35,8 @@ export type UserContext = { app_user_id: string user_id: string } -export type UserMethodInputs = AddProduct_Input | AddUserOffer_Input | AuthorizeDebit_Input | AuthorizeManage_Input | BanDebit_Input | DecodeInvoice_Input | DeleteUserOffer_Input | EditDebit_Input | EnrollAdminToken_Input | GetDebitAuthorizations_Input | GetHttpCreds_Input | GetLNURLChannelLink_Input | GetLnurlPayLink_Input | GetLnurlWithdrawLink_Input | GetManageAuthorizations_Input | GetPaymentState_Input | GetUserInfo_Input | GetUserOffer_Input | GetUserOfferInvoices_Input | GetUserOffers_Input | GetUserOperations_Input | NewAddress_Input | NewInvoice_Input | NewProductInvoice_Input | PayAddress_Input | PayInvoice_Input | ResetDebit_Input | ResetManage_Input | RespondToDebit_Input | UpdateCallbackUrl_Input | UpdateUserOffer_Input | UserHealth_Input -export type UserMethodOutputs = AddProduct_Output | AddUserOffer_Output | AuthorizeDebit_Output | AuthorizeManage_Output | BanDebit_Output | DecodeInvoice_Output | DeleteUserOffer_Output | EditDebit_Output | EnrollAdminToken_Output | GetDebitAuthorizations_Output | GetHttpCreds_Output | GetLNURLChannelLink_Output | GetLnurlPayLink_Output | GetLnurlWithdrawLink_Output | GetManageAuthorizations_Output | GetPaymentState_Output | GetUserInfo_Output | GetUserOffer_Output | GetUserOfferInvoices_Output | GetUserOffers_Output | GetUserOperations_Output | NewAddress_Output | NewInvoice_Output | NewProductInvoice_Output | PayAddress_Output | PayInvoice_Output | ResetDebit_Output | ResetManage_Output | RespondToDebit_Output | UpdateCallbackUrl_Output | UpdateUserOffer_Output | UserHealth_Output +export type UserMethodInputs = AddProduct_Input | AddUserOffer_Input | AuthorizeDebit_Input | AuthorizeManage_Input | BanDebit_Input | DecodeInvoice_Input | DeleteUserOffer_Input | EditDebit_Input | EnrollAdminToken_Input | EnrollMessagingToken_Input | GetDebitAuthorizations_Input | GetHttpCreds_Input | GetLNURLChannelLink_Input | GetLnurlPayLink_Input | GetLnurlWithdrawLink_Input | GetManageAuthorizations_Input | GetPaymentState_Input | GetUserInfo_Input | GetUserOffer_Input | GetUserOfferInvoices_Input | GetUserOffers_Input | GetUserOperations_Input | NewAddress_Input | NewInvoice_Input | NewProductInvoice_Input | PayAddress_Input | PayInvoice_Input | ResetDebit_Input | ResetManage_Input | RespondToDebit_Input | UpdateCallbackUrl_Input | UpdateUserOffer_Input | UserHealth_Input +export type UserMethodOutputs = AddProduct_Output | AddUserOffer_Output | AuthorizeDebit_Output | AuthorizeManage_Output | BanDebit_Output | DecodeInvoice_Output | DeleteUserOffer_Output | EditDebit_Output | EnrollAdminToken_Output | EnrollMessagingToken_Output | GetDebitAuthorizations_Output | GetHttpCreds_Output | GetLNURLChannelLink_Output | GetLnurlPayLink_Output | GetLnurlWithdrawLink_Output | GetManageAuthorizations_Output | GetPaymentState_Output | GetUserInfo_Output | GetUserOffer_Output | GetUserOfferInvoices_Output | GetUserOffers_Output | GetUserOperations_Output | NewAddress_Output | NewInvoice_Output | NewProductInvoice_Output | PayAddress_Output | PayInvoice_Output | ResetDebit_Output | ResetManage_Output | RespondToDebit_Output | UpdateCallbackUrl_Output | UpdateUserOffer_Output | UserHealth_Output export type AuthContext = AdminContext | AppContext | GuestContext | GuestWithPubContext | MetricsContext | UserContext export type AddApp_Input = {rpcName:'AddApp', req: AddAppRequest} @@ -99,6 +99,9 @@ export type EncryptionExchange_Output = ResultError | { status: 'OK' } export type EnrollAdminToken_Input = {rpcName:'EnrollAdminToken', req: EnrollAdminTokenRequest} export type EnrollAdminToken_Output = ResultError | { status: 'OK' } +export type EnrollMessagingToken_Input = {rpcName:'EnrollMessagingToken', req: MessagingToken} +export type EnrollMessagingToken_Output = ResultError | { status: 'OK' } + export type GetApp_Input = {rpcName:'GetApp'} export type GetApp_Output = ResultError | ({ status: 'OK' } & Application) @@ -342,6 +345,7 @@ export type ServerMethods = { EditDebit?: (req: EditDebit_Input & {ctx: UserContext }) => Promise EncryptionExchange?: (req: EncryptionExchange_Input & {ctx: GuestContext }) => Promise EnrollAdminToken?: (req: EnrollAdminToken_Input & {ctx: UserContext }) => Promise + EnrollMessagingToken?: (req: EnrollMessagingToken_Input & {ctx: UserContext }) => Promise GetApp?: (req: GetApp_Input & {ctx: AppContext }) => Promise GetAppUser?: (req: GetAppUser_Input & {ctx: AppContext }) => Promise GetAppUserLNURLInfo?: (req: GetAppUserLNURLInfo_Input & {ctx: AppContext }) => Promise @@ -2610,6 +2614,29 @@ export const ManageOperationValidate = (o?: ManageOperation, opts: ManageOperati return null } +export type MessagingToken = { + device_id: string + firebase_messaging_token: string +} +export const MessagingTokenOptionalFields: [] = [] +export type MessagingTokenOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + device_id_CustomCheck?: (v: string) => boolean + firebase_messaging_token_CustomCheck?: (v: string) => boolean +} +export const MessagingTokenValidate = (o?: MessagingToken, opts: MessagingTokenOptions = {}, path: string = 'MessagingToken::root.'): Error | null => { + if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') + if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + + if (typeof o.device_id !== 'string') return new Error(`${path}.device_id: is not a string`) + if (opts.device_id_CustomCheck && !opts.device_id_CustomCheck(o.device_id)) return new Error(`${path}.device_id: custom check failed`) + + if (typeof o.firebase_messaging_token !== 'string') return new Error(`${path}.firebase_messaging_token: is not a string`) + if (opts.firebase_messaging_token_CustomCheck && !opts.firebase_messaging_token_CustomCheck(o.firebase_messaging_token)) return new Error(`${path}.firebase_messaging_token: custom check failed`) + + return null +} + export type MetricsFile = { } export const MetricsFileOptionalFields: [] = [] diff --git a/proto/service/methods.proto b/proto/service/methods.proto index b20bdb73..f9bda8cd 100644 --- a/proto/service/methods.proto +++ b/proto/service/methods.proto @@ -672,6 +672,12 @@ service LightningPub { option (http_route) = "/api/user/http_creds"; option (nostr) = true; } + rpc EnrollMessagingToken(structs.MessagingToken) returns (structs.Empty){ + option (auth_type) = "User"; + option (http_method) = "post"; + option (http_route) = "/api/user/messaging/enroll"; + option (nostr) = true; + } rpc BatchUser(structs.Empty) returns (structs.Empty){ option (auth_type) = "User"; option (http_method) = "post"; diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 2b976afe..56f28b2e 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -806,4 +806,7 @@ message ProvidersDisruption { repeated ProviderDisruption disruptions = 1; } - +message MessagingToken { + string device_id = 1; + string firebase_messaging_token = 2; +} diff --git a/src/services/ShockPush/autogenerated/http_client.ts b/src/services/ShockPush/autogenerated/http_client.ts new file mode 100644 index 00000000..1a29dff6 --- /dev/null +++ b/src/services/ShockPush/autogenerated/http_client.ts @@ -0,0 +1,51 @@ +// This file was autogenerated from a .proto file, DO NOT EDIT! +import axios from 'axios' +import * as Types from './types.js' +export type ResultError = { status: 'ERROR', reason: string } + +export type ClientParams = { + baseUrl: string + retrieveAdminAuth: () => Promise + retrieveGuestAuth: () => Promise + retrieveNostrAppAuth: (rawBody: string, reqUrl: string, httpMethod: string) => Promise + encryptCallback: (plain: any) => Promise + decryptCallback: (encrypted: any) => Promise + deviceId: string + checkResult?: true +} +export default (params: ClientParams) => ({ + EnrollServicePub: async (request: Types.ServiceNpub): Promise => { + let finalRoute = '/api/admin/service/enroll' + const auth = await params.retrieveAdminAuth() + if (auth === null) throw new Error('retrieveAdminAuth() returned null') + const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + return data + } + return { status: 'ERROR', reason: 'invalid response' } + }, + Health: async (): Promise => { + let finalRoute = '/api/health' + const auth = await params.retrieveGuestAuth() + if (auth === null) throw new Error('retrieveGuestAuth() returned null') + const { data } = await axios.get(params.baseUrl + finalRoute, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + return data + } + return { status: 'ERROR', reason: 'invalid response' } + }, + SendNotification: async (request: Types.Notification): Promise => { + let finalRoute = '/api/user/notification' + const rawBody = JSON.stringify(request) + const auth = await params.retrieveNostrAppAuth(rawBody, finalRoute, 'post') + if (auth === null) throw new Error('retrieveNostrAppAuth() returned null') + const { data } = await axios.post(params.baseUrl + finalRoute, rawBody, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + return data + } + return { status: 'ERROR', reason: 'invalid response' } + }, +}) diff --git a/src/services/ShockPush/autogenerated/types.ts b/src/services/ShockPush/autogenerated/types.ts new file mode 100644 index 00000000..80416c32 --- /dev/null +++ b/src/services/ShockPush/autogenerated/types.ts @@ -0,0 +1,104 @@ +// This file was autogenerated from a .proto file, DO NOT EDIT! + +export type ResultError = { status: 'ERROR', reason: string } +export type RequestInfo = { rpcName: string, batch: boolean, nostr: boolean, batchSize: number } +export type RequestStats = { startMs: number, start: bigint, parse: bigint, guard: bigint, validate: bigint, handle: bigint } +export type RequestMetric = AuthContext & RequestInfo & RequestStats & { error?: string } +export type ProtoSocketState = 'OPEN' | 'CLOSED' +export type ProtoSocket = { + getState: () => ProtoSocketState + send: (res: T, err: Error | null) => void +} +export type AdminContext = { + admin_id: string +} +export type AdminMethodInputs = EnrollServicePub_Input +export type AdminMethodOutputs = EnrollServicePub_Output +export type GuestContext = { +} +export type GuestMethodInputs = Health_Input +export type GuestMethodOutputs = Health_Output +export type NostrAppContext = { + nostr_app_npub: string +} +export type NostrAppMethodInputs = SendNotification_Input +export type NostrAppMethodOutputs = SendNotification_Output +export type AuthContext = AdminContext | GuestContext | NostrAppContext + +export type EnrollServicePub_Input = { rpcName: 'EnrollServicePub', req: ServiceNpub } +export type EnrollServicePub_Output = ResultError | { status: 'OK' } + +export type Health_Input = { rpcName: 'Health' } +export type Health_Output = ResultError | { status: 'OK' } + +export type SendNotification_Input = { rpcName: 'SendNotification', req: Notification } +export type SendNotification_Output = ResultError | { status: 'OK' } + +export type ServerMethods = { + EnrollServicePub?: (req: EnrollServicePub_Input & { ctx: AdminContext }) => Promise + Health?: (req: Health_Input & { ctx: GuestContext }) => Promise + SendNotification?: (req: SendNotification_Input & { ctx: NostrAppContext }) => Promise +} + + +export type OptionsBaseMessage = { + allOptionalsAreSet?: true +} + +export type Empty = { +} +export const EmptyOptionalFields: [] = [] +export type EmptyOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] +} +export const EmptyValidate = (o?: Empty, opts: EmptyOptions = {}, path: string = 'Empty::root.'): Error | null => { + if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') + if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + + return null +} + +export type Notification = { + data: string + recipient_registration_tokens: string[] +} +export const NotificationOptionalFields: [] = [] +export type NotificationOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + data_CustomCheck?: (v: string) => boolean + recipient_registration_tokens_CustomCheck?: (v: string[]) => boolean +} +export const NotificationValidate = (o?: Notification, opts: NotificationOptions = {}, path: string = 'Notification::root.'): Error | null => { + if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') + if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + + if (typeof o.data !== 'string') return new Error(`${path}.data: is not a string`) + if (opts.data_CustomCheck && !opts.data_CustomCheck(o.data)) return new Error(`${path}.data: custom check failed`) + + if (!Array.isArray(o.recipient_registration_tokens)) return new Error(`${path}.recipient_registration_tokens: is not an array`) + for (let index = 0; index < o.recipient_registration_tokens.length; index++) { + if (typeof o.recipient_registration_tokens[index] !== 'string') return new Error(`${path}.recipient_registration_tokens[${index}]: is not a string`) + } + if (opts.recipient_registration_tokens_CustomCheck && !opts.recipient_registration_tokens_CustomCheck(o.recipient_registration_tokens)) return new Error(`${path}.recipient_registration_tokens: custom check failed`) + + return null +} + +export type ServiceNpub = { + npub: string +} +export const ServiceNpubOptionalFields: [] = [] +export type ServiceNpubOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + npub_CustomCheck?: (v: string) => boolean +} +export const ServiceNpubValidate = (o?: ServiceNpub, opts: ServiceNpubOptions = {}, path: string = 'ServiceNpub::root.'): Error | null => { + if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') + if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + + if (typeof o.npub !== 'string') return new Error(`${path}.npub: is not a string`) + if (opts.npub_CustomCheck && !opts.npub_CustomCheck(o.npub)) return new Error(`${path}.npub: custom check failed`) + + return null +} + diff --git a/src/services/ShockPush/index.ts b/src/services/ShockPush/index.ts new file mode 100644 index 00000000..cbf3fa77 --- /dev/null +++ b/src/services/ShockPush/index.ts @@ -0,0 +1,61 @@ +import { nip98, UnsignedEvent, finalizeEvent } from 'nostr-tools' +import { bytesToHex } from '@noble/hashes/utils' +import { sha256 } from '@noble/hashes/sha256' +import { base64 } from '@scure/base'; +import NewClient, { ClientParams } from './autogenerated/http_client.js' +import { ERROR, getLogger } from '../helpers/logger.js' +const utf8Encoder = new TextEncoder() +export type PushPair = { pubkey: string, privateKey: string } +const nip98Kind = 27235 +export class ShockPush { + private client: ReturnType + private logger: ReturnType + private serviceBaseUrl: string + private pair: PushPair + constructor(shockPushUrl: string, pair: PushPair) { + this.logger = getLogger({ component: 'shockPush' }) + this.serviceBaseUrl = shockPushUrl + this.pair = pair + this.client = NewClient({ + baseUrl: this.serviceBaseUrl, + retrieveAdminAuth: async () => { throw new Error('not implemented') }, + retrieveGuestAuth: async () => (''), + retrieveNostrAppAuth: async (rawBody, reqUrl, httpMethod) => this.generateNip98Header(rawBody, reqUrl, httpMethod), + encryptCallback: () => { throw new Error('not implemented') }, + decryptCallback: () => { throw new Error('not implemented') }, + deviceId: '', + }) + } + + private generateNip98Header = async (raw: string, url: string, method: string) => { + const tags = [ + ["u", `${this.serviceBaseUrl}${url}`], + ["method", method.toUpperCase()] + ]; + + if (raw !== "") { + tags.push(["payload", bytesToHex(sha256(utf8Encoder.encode(raw)))]); + } + + const npub = this.pair.pubkey + + const event: UnsignedEvent = { + created_at: Math.round(Date.now() / 1000), + pubkey: npub, + content: "", + kind: nip98Kind, + tags + } + + const signed = finalizeEvent(event, Buffer.from(this.pair.privateKey, 'hex')) + const nip98Header = "Nostr " + base64.encode(utf8Encoder.encode(JSON.stringify(signed))); + return nip98Header + } + + SendNotification = async (message: string, messagingToken: string) => { + const res = await this.client.SendNotification({ recipient_registration_tokens: [messagingToken], data: message }) + if (res.status !== 'OK') { + this.logger(ERROR, `failed to send notification: ${res.status}`) + } + } +} \ No newline at end of file diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index 12eae1f9..751176ea 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -111,4 +111,10 @@ export default class { user_identifier: ctx.app_user_id }) } + + async EnrollMessagingToken(ctx: Types.UserContext, req: Types.MessagingToken): Promise { + const app = await this.storage.applicationStorage.GetApplication(ctx.app_id); + const user = await this.storage.applicationStorage.GetApplicationUser(app, ctx.app_user_id); + await this.storage.applicationStorage.UpdateAppUserMessagingToken(user.identifier, req.device_id, req.firebase_messaging_token); + } } \ No newline at end of file diff --git a/src/services/main/applicationManager.ts b/src/services/main/applicationManager.ts index aa00b9b3..d1fa6a7b 100644 --- a/src/services/main/applicationManager.ts +++ b/src/services/main/applicationManager.ts @@ -316,4 +316,6 @@ export default class { await this.storage.applicationStorage.SetInviteTokenAsUsed(inviteToken); } + + } \ No newline at end of file diff --git a/src/services/main/index.ts b/src/services/main/index.ts index f426c51d..05cb4128 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -28,6 +28,7 @@ import { parse } from "uri-template" import webRTC from "../webRTC/index.js" import { ManagementManager } from "./managementManager.js" import { Agent } from "https" +import { NotificationsManager } from "./notificationsManager.js" type UserOperationsSub = { id: string @@ -58,6 +59,7 @@ export default class { utils: Utils rugPullTracker: RugPullTracker unlocker: Unlocker + notificationsManager: NotificationsManager //webRTC: webRTC nostrSend: NostrSend = () => { getLogger({})("nostr send not initialized yet") } nostrProcessPing: (() => Promise) | null = null @@ -81,6 +83,7 @@ export default class { this.debitManager = new DebitManager(this.storage, this.lnd, this.applicationManager) this.offerManager = new OfferManager(this.storage, this.settings, this.lnd, this.applicationManager, this.productManager, this.liquidityManager) this.managementManager = new ManagementManager(this.storage, this.settings) + this.notificationsManager = new NotificationsManager(this.settings.shockPushBaseUrl) //this.webRTC = new webRTC(this.storage, this.utils) } @@ -287,12 +290,13 @@ export default class { async triggerPaidCallback(log: PubLogger, url: string, { invoice, amount, payerData, token, rejectUnauthorized }: - { invoice: string, - amount: number, - payerData?: Record, - token?: string, - rejectUnauthorized?: boolean - } + { + invoice: string, + amount: number, + payerData?: Record, + token?: string, + rejectUnauthorized?: boolean + } ) { if (!url) { return @@ -357,8 +361,16 @@ export default class { getLogger({ appName: app.name })("cannot notify user, not a nostr user") return } + const devices = await this.storage.applicationStorage.GetAppUserDevices(user.identifier) const message: Types.LiveUserOperation & { requestId: string, status: 'OK' } = { operation: op, requestId: "GetLiveUserOperations", status: 'OK' } - this.nostrSend({ type: 'app', appId: app.app_id }, { type: 'content', content: JSON.stringify(message), pub: user.nostr_public_key }) + const j = JSON.stringify(message) + this.nostrSend({ type: 'app', appId: app.app_id }, { type: 'content', content: j, pub: user.nostr_public_key }) + for (const device of devices) { + this.notificationsManager.SendNotification(JSON.stringify(message), device.firebase_messaging_token, { + pubkey: app.nostr_public_key!, + privateKey: app.nostr_private_key! + }) + } } async UpdateBeacon(app: Application, content: { type: 'service', name: string }) { diff --git a/src/services/main/notificationsManager.ts b/src/services/main/notificationsManager.ts new file mode 100644 index 00000000..003148f6 --- /dev/null +++ b/src/services/main/notificationsManager.ts @@ -0,0 +1,31 @@ +import { PushPair, ShockPush } from "../ShockPush" +import { getLogger, PubLogger } from "../helpers/logger" + +export class NotificationsManager { + private shockPushBaseUrl: string + private clients: Record = {} + private logger: PubLogger + constructor(shockPushBaseUrl: string) { + this.shockPushBaseUrl = shockPushBaseUrl + this.logger = getLogger({ component: 'notificationsManager' }) + } + + private getClient = (pair: PushPair) => { + const client = this.clients[pair.pubkey] + if (client) { + return client + } + const newClient = new ShockPush(this.shockPushBaseUrl, pair) + this.clients[pair.pubkey] = newClient + return newClient + } + + SendNotification = async (message: string, messagingToken: string, pair: PushPair) => { + if (!this.shockPushBaseUrl) { + this.logger("ShockPush is not configured, skipping notification") + return + } + const client = this.getClient(pair) + await client.SendNotification(message, messagingToken) + } +} \ No newline at end of file diff --git a/src/services/main/settings.ts b/src/services/main/settings.ts index 5c831c75..18150d52 100644 --- a/src/services/main/settings.ts +++ b/src/services/main/settings.ts @@ -39,6 +39,7 @@ export type MainSettings = { bridgeUrl: string, allowResetMetricsStorages: boolean allowHttpUpgrade: boolean + shockPushBaseUrl: string } export type BitcoinCoreSettings = { @@ -81,7 +82,8 @@ export const LoadMainSettingsFromEnv = (): MainSettings => { lnurlMetaText: process.env.LNURL_META_TEXT || "LNURL via Lightning.pub", bridgeUrl: process.env.BRIDGE_URL || "https://shockwallet.app", allowResetMetricsStorages: process.env.ALLOW_RESET_METRICS_STORAGES === 'true' || false, - allowHttpUpgrade: process.env.ALLOW_HTTP_UPGRADE === 'true' || false + allowHttpUpgrade: process.env.ALLOW_HTTP_UPGRADE === 'true' || false, + shockPushBaseUrl: process.env.SHOCK_PUSH_URL || "" } } diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index 653eadb9..5df1a734 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -426,5 +426,13 @@ export default (mainHandler: Main): Types.ServerMethods => { GetHttpCreds: async ({ ctx }) => { return mainHandler.appUserManager.GetHttpCreds(ctx) }, + EnrollMessagingToken: async ({ ctx, req }) => { + const err = Types.MessagingTokenValidate(req, { + device_id_CustomCheck: id => id !== '', + firebase_messaging_token_CustomCheck: token => token !== '' + }) + if (err != null) throw new Error(err.message) + return mainHandler.appUserManager.EnrollMessagingToken(ctx, req) + }, } } diff --git a/src/services/storage/applicationStorage.ts b/src/services/storage/applicationStorage.ts index c5b87bb4..b00b5f81 100644 --- a/src/services/storage/applicationStorage.ts +++ b/src/services/storage/applicationStorage.ts @@ -8,6 +8,7 @@ import { getLogger } from '../helpers/logger.js'; import { User } from './entity/User.js'; import { InviteToken } from './entity/InviteToken.js'; import { StorageInterface } from './db/storageInterface.js'; +import { AppUserDevice } from './entity/AppUserDevice.js'; export default class { dbs: StorageInterface userStorage: UserStorage @@ -178,4 +179,23 @@ export default class { return this.dbs.Update('InviteToken', inviteToken, { used: true }) } + + async UpdateAppUserMessagingToken(appUserId: string, deviceId: string, firebaseMessagingToken: string) { + const existing = await this.dbs.FindOne('AppUserDevice', { where: { app_user_id: appUserId, device_id: deviceId } }) + if (!existing) { + return this.dbs.CreateAndSave('AppUserDevice', { + app_user_id: appUserId, + device_id: deviceId, + firebase_messaging_token: firebaseMessagingToken + }) + } + if (existing.firebase_messaging_token === firebaseMessagingToken) { + return + } + return this.dbs.Update('AppUserDevice', existing.serial_id, { firebase_messaging_token: firebaseMessagingToken }) + } + + async GetAppUserDevices(appUserId: string, txId?: string) { + return this.dbs.Find('AppUserDevice', { where: { app_user_id: appUserId } }, txId) + } } \ No newline at end of file diff --git a/src/services/storage/db/db.ts b/src/services/storage/db/db.ts index 770476a9..e0e8ac43 100644 --- a/src/services/storage/db/db.ts +++ b/src/services/storage/db/db.ts @@ -26,6 +26,7 @@ import { RootOperation } from "../entity/RootOperation.js" import { UserOffer } from "../entity/UserOffer.js" import { ManagementGrant } from "../entity/ManagementGrant.js" import { ChannelEvent } from "../entity/ChannelEvent.js" +import { AppUserDevice } from "../entity/AppUserDevice.js" export type DbSettings = { @@ -68,7 +69,8 @@ export const MainDbEntities = { 'DebitAccess': DebitAccess, 'UserOffer': UserOffer, 'Product': Product, - 'ManagementGrant': ManagementGrant + 'ManagementGrant': ManagementGrant, + 'AppUserDevice': AppUserDevice } export type MainDbNames = keyof typeof MainDbEntities export const MainDbEntitiesNames = Object.keys(MainDbEntities) diff --git a/src/services/storage/entity/AppUserDevice.ts b/src/services/storage/entity/AppUserDevice.ts new file mode 100644 index 00000000..19a8f2f2 --- /dev/null +++ b/src/services/storage/entity/AppUserDevice.ts @@ -0,0 +1,22 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from "typeorm"; + +@Entity() +export class AppUserDevice { + @PrimaryGeneratedColumn() + serial_id: number + + @Column() + app_user_id: string + + @Column() + device_id: string + + @Column() + firebase_messaging_token: string + + @CreateDateColumn() + created_at: Date + + @UpdateDateColumn() + updated_at: Date +} \ No newline at end of file diff --git a/src/services/storage/migrations/1753285173175-app_user_device.ts b/src/services/storage/migrations/1753285173175-app_user_device.ts new file mode 100644 index 00000000..2aeb68ce --- /dev/null +++ b/src/services/storage/migrations/1753285173175-app_user_device.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AppUserDevice1753285173175 implements MigrationInterface { + name = 'AppUserDevice1753285173175' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "app_user_device" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "app_user_id" varchar NOT NULL, "device_id" varchar NOT NULL, "firebase_messaging_token" varchar NOT NULL, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "app_user_device"`); + } +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index ec68c749..e04ff79e 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -20,9 +20,11 @@ import { ChannelEvents1750777346411 } from './1750777346411-channel_events.js' import { ManagementGrant1751307732346 } from './1751307732346-management_grant.js' import { ManagementGrantBanned1751989251513 } from './1751989251513-management_grant_banned.js' import { InvoiceCallbackUrls1752425992291 } from './1752425992291-invoice_callback_urls.js' +import { AppUserDevice1753285173175 } from './1753285173175-app_user_device.js' export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, - DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, InvoiceCallbackUrls1752425992291] + DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, + InvoiceCallbackUrls1752425992291, AppUserDevice1753285173175] export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] /* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise => { await connectAndMigrate(log, storageManager, allMigrations, allMetricsMigrations) diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index e337f9ea..ea92ad88 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { Between, FindOperator, IsNull, LessThanOrEqual, MoreThan, MoreThanOrEqual, Not } from "typeorm" +import { And, Between, Equal, FindOperator, IsNull, LessThanOrEqual, MoreThan, MoreThanOrEqual, Not } from "typeorm" import { User } from './entity/User.js'; import { UserTransactionPayment } from './entity/UserTransactionPayment.js'; import { EphemeralKeyType, UserEphemeralKey } from './entity/UserEphemeralKey.js'; @@ -14,7 +14,7 @@ import { Application } from './entity/Application.js'; import TransactionsQueue from "./db/transactionsQueue.js"; import { LoggedEvent } from './eventsLog.js'; import { StorageInterface } from './db/storageInterface.js'; -export type InboundOptionals = { product?: Product, callbackUrl?: string, expiry: number, expectedPayer?: User, linkedApplication?: Application, zapInfo?: ZapInfo, offerId?: string, payerData?: Record , rejectUnauthorized?: boolean, token?: string} +export type InboundOptionals = { product?: Product, callbackUrl?: string, expiry: number, expectedPayer?: User, linkedApplication?: Application, zapInfo?: ZapInfo, offerId?: string, payerData?: Record, rejectUnauthorized?: boolean, token?: string } export const defaultInvoiceExpiry = 60 * 60 export default class { dbs: StorageInterface @@ -73,6 +73,38 @@ export default class { return this.dbs.Update('UserReceivingInvoice', invoice.serial_id, i, txId) } + + + async GetUserInvoicesFlaggedAsPaid2(serialId: number, fromIndex: number, fromTs: number, take = 50, txId?: string): Promise { + let items = await this.dbs.Find('UserReceivingInvoice', { + where: { + user: { serial_id: serialId }, + paid_at_unix: And(MoreThan(0), Equal(fromTs)), + serial_id: MoreThan(fromIndex) + }, + order: { + paid_at_unix: 'DESC', + serial_id: 'DESC' + }, + take + }, txId) + const more = take - items.length + if (more > 0) { + const more = await this.dbs.Find('UserReceivingInvoice', { + where: { + user: { serial_id: serialId }, + paid_at_unix: And(MoreThan(0), MoreThan(fromTs)), + }, + order: { + paid_at_unix: 'DESC', + serial_id: 'DESC' + }, + take + }, txId) + items.push(...more) + } + return items + } GetUserInvoicesFlaggedAsPaid(userId: string, fromIndex: number, take = 50, txId?: string): Promise { return this.dbs.Find('UserReceivingInvoice', { where: {