From 9563498ec664651e993ae6ce762796782d1232d1 Mon Sep 17 00:00:00 2001 From: Conrad Bekondo Date: Mon, 3 Feb 2025 14:24:06 +0100 Subject: [PATCH] feat: device identification on analytics --- lib/models/wallet.ts | 52 ++++++--- package.json | 2 + pnpm-lock.yaml | 78 +++++++++++++ scripts/seed.ts | 26 ----- src/app/app.routes.ts | 5 + src/app/directives/index.ts | 2 + .../recaptcha/re-captcha.directive.ts | 66 +++++++++++ .../interceptors/access-token.interceptor.ts | 2 +- .../pages/analytics/analytics.component.html | 9 ++ .../pages/analytics/analytics.component.scss | 3 + .../pages/analytics/analytics.component.ts | 106 ++++++++++++++++++ src/app/pages/wallet/wallet.component.html | 94 ++++++++++------ src/app/pages/wallet/wallet.component.ts | 5 +- 13 files changed, 370 insertions(+), 80 deletions(-) delete mode 100644 scripts/seed.ts create mode 100644 src/app/directives/index.ts create mode 100644 src/app/directives/recaptcha/re-captcha.directive.ts create mode 100644 src/app/pages/analytics/analytics.component.html create mode 100644 src/app/pages/analytics/analytics.component.scss create mode 100644 src/app/pages/analytics/analytics.component.ts diff --git a/lib/models/wallet.ts b/lib/models/wallet.ts index d8e0a5b..ef9e56f 100644 --- a/lib/models/wallet.ts +++ b/lib/models/wallet.ts @@ -1,21 +1,43 @@ import { z } from "zod"; export const WalletTransferSchema = z.object({ - id: z.string(), - from: z.string().uuid().nullable(), - to: z.string().uuid(), - amount: z.number(), - status: z.enum(['pending', 'cancelled', 'complete']), - type: z.enum(['funding', 'reward', 'withdrawal']), - date: z.date(), - payment: z.object({ - id: z.string().uuid(), - currency: z.string(), - amount: z.number(), - status: z.enum(['pending', 'cancelled', 'complete']) - }).nullable() + burst: z.union([z.string(), z.date()]).pipe(z.coerce.date()), + creditAllocationId: z.string().uuid().nullable(), + creditAllocation: z + .object({ + allocated: z.union([z.string(), z.number()]).pipe(z.coerce.number()), + status: z.string(), + }) + .nullable(), + credits: z.union([z.string(), z.number()]).pipe(z.coerce.number()), + notes: z.string().nullable(), + paymentTransaction: z + .object({ + status: z.string(), + amount: z.union([z.string(), z.number()]).pipe(z.coerce.number()), + currency: z.string(), + }) + .nullable(), + paymentTransactionId: z.string().nullable(), + recordedAt: z.union([z.string(), z.date()]).pipe(z.coerce.date()), + status: z.string(), + transaction: z.string().uuid(), + type: z.string(), }); +export const WalletTransferGroupSchema = z.object({ + burst: z.union([z.string(), z.date()]).pipe(z.coerce.date()), + fundingRewardsRatio: z + .union([z.string(), z.number()]) + .pipe(z.coerce.number()), + owner: z.union([z.string(), z.number()]).pipe(z.coerce.number()), + transferredCredits: z.union([z.string(), z.number()]).pipe(z.coerce.number()), + wallet: z.string().uuid(), + transfers: z.array(WalletTransferSchema), +}); + + + export const WalletBalanceSchema = z.object({ balance: z.union([z.string(), z.number()]).pipe(z.coerce.number()), ownerId: z.number(), @@ -29,8 +51,8 @@ export const BalancesSchema = z.object({ export const WalletTransfersResponseSchema = z.object({ total: z.number(), - data: z.array(WalletTransferSchema) -}) + groups: z.array(WalletTransferGroupSchema), +}); export type WalletBalanceInfo = z.infer; export type WalletTransfer = z.infer; diff --git a/package.json b/package.json index 508b511..abfcf92 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@angular/platform-browser": "^19.1.1", "@angular/platform-browser-dynamic": "^19.1.1", "@angular/router": "^19.1.1", + "@aws-crypto/sha256-js": "^5.2.0", "@ngxs/devtools-plugin": "^19.0.0", "@ngxs/logger-plugin": "^19.0.0", "@ngxs/router-plugin": "^19.0.0", @@ -37,6 +38,7 @@ "tailwindcss-primeui": "^0.3.4", "tslib": "^2.8.1", "zod": "^3.24.1", + "zod-validation-error": "^3.4.0", "zone.js": "~0.15.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb2d365..ff17139 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@angular/router': specifier: ^19.1.1 version: 19.1.3(@angular/common@19.1.1(@angular/core@19.1.1(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@angular/core@19.1.1(rxjs@7.8.1)(zone.js@0.15.0))(@angular/platform-browser@19.1.1(@angular/animations@19.1.1(@angular/core@19.1.1(rxjs@7.8.1)(zone.js@0.15.0)))(@angular/common@19.1.1(@angular/core@19.1.1(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@angular/core@19.1.1(rxjs@7.8.1)(zone.js@0.15.0)))(rxjs@7.8.1) + '@aws-crypto/sha256-js': + specifier: ^5.2.0 + version: 5.2.0 '@ngxs/devtools-plugin': specifier: ^19.0.0 version: 19.0.0(@angular/core@19.1.1(rxjs@7.8.1)(zone.js@0.15.0))(@ngxs/store@19.0.0(@angular/core@19.1.1(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(rxjs@7.8.1) @@ -86,6 +89,9 @@ importers: zod: specifier: ^3.24.1 version: 3.24.1 + zod-validation-error: + specifier: ^3.4.0 + version: 3.4.0(zod@3.24.1) zone.js: specifier: ~0.15.0 version: 0.15.0 @@ -343,6 +349,17 @@ packages: '@angular/platform-browser': 19.1.3 rxjs: ^6.5.3 || ^7.4.0 + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/types@3.734.0': + resolution: {integrity: sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -1719,6 +1736,22 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/types@4.1.0': + resolution: {integrity: sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -5201,6 +5234,12 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} @@ -5494,6 +5533,23 @@ snapshots: rxjs: 7.8.1 tslib: 2.8.1 + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.734.0 + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.734.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.734.0': + dependencies: + '@smithy/types': 4.1.0 + tslib: 2.8.1 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -6905,6 +6961,24 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/types@4.1.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} '@ts-morph/common@0.23.0': @@ -10681,6 +10755,10 @@ snapshots: yoctocolors-cjs@2.1.2: {} + zod-validation-error@3.4.0(zod@3.24.1): + dependencies: + zod: 3.24.1 + zod@3.24.1: {} zone.js@0.15.0: {} diff --git a/scripts/seed.ts b/scripts/seed.ts deleted file mode 100644 index 0258701..0000000 --- a/scripts/seed.ts +++ /dev/null @@ -1,26 +0,0 @@ -import 'dotenv/config'; -import { drizzle } from 'drizzle-orm/neon-serverless'; -import { PgTransaction } from 'drizzle-orm/pg-core'; -import * as categories from '../db/seed/categories'; -import * as users from '../db/seed/users'; -import * as wallets from '../db/seed/wallets'; -import { DefaultWriter } from '../db/log-writer'; -import { DefaultLogger } from 'drizzle-orm/logger'; -import { neonConfig, Pool } from '@neondatabase/serverless'; -import ws from 'ws'; - -type Seeder = { name: string, seed: (t: PgTransaction) => Promise } -const seeders: Seeder[] = [users, categories, wallets]; -neonConfig.webSocketConstructor = global.WebSocket ?? ws; - -const logger = process.env['NODE_ENV'] === 'development' ? new DefaultLogger({ writer: new DefaultWriter() }) : false -console.log('db url = ', process.env['DATABASE_URL']) -const db = drizzle(new Pool({ connectionString: String(process.env['DATABASE_URL']) }), { logger }); -db.transaction(async t => { - for await (const { seed, name } of seeders) { - console.log(`Seeding "${name} ⚙️`); - await seed(t as unknown as PgTransaction); - console.log(`Seeded "${name}" ✅`); - } -}).then(() => db.$client.end()) - .catch(console.error); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 13b3867..eef52f6 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,6 +6,11 @@ import { NotFoundComponent } from './pages/not-found/not-found.component'; const signedInGuard = authGuard('/auth/login'); export const appRoutes: Routes = [ + { + path: 'analytics', + title: 'Analytics', + loadComponent: () => import('./pages/analytics/analytics.component').then(m => m.AnalyticsComponent) + }, { path: 'auth', loadChildren: () => import('./auth.routes').then(m => m.authRoutes) diff --git a/src/app/directives/index.ts b/src/app/directives/index.ts new file mode 100644 index 0000000..8a23189 --- /dev/null +++ b/src/app/directives/index.ts @@ -0,0 +1,2 @@ +export { PhoneDirective } from './phone.directive'; +export { Recaptcha } from './recaptcha/re-captcha.directive'; diff --git a/src/app/directives/recaptcha/re-captcha.directive.ts b/src/app/directives/recaptcha/re-captcha.directive.ts new file mode 100644 index 0000000..775bcc4 --- /dev/null +++ b/src/app/directives/recaptcha/re-captcha.directive.ts @@ -0,0 +1,66 @@ +import { Directive, input, OnDestroy, OnInit, output } from '@angular/core'; +import { AsyncSubject, lastValueFrom, switchMap } from 'rxjs'; + +declare global { + interface Window { + grecaptcha: { + ready: (fn: () => void) => void; + execute: (key: string, opts: { action: string }) => Promise; + }, + recaptchaLoaded?: () => void; + } +} + +@Directive({ + selector: '[tmReCaptcha]' +}) +export class Recaptcha implements OnInit, OnDestroy { + private static scriptElement?: HTMLScriptElement; + private static instanceCount = 0; + private static loaded = new AsyncSubject(); + readonly recaptchaKey = input.required(); + readonly ready = output(); + + constructor() { + Recaptcha.instanceCount++; + } + + async getToken(action: string) { + if (Recaptcha.loaded.closed) + return await window.grecaptcha.execute(this.recaptchaKey(), { action }); + return await lastValueFrom(Recaptcha.loaded.pipe(switchMap(() => window.grecaptcha.execute(this.recaptchaKey(), { action })))); + } + + ngOnInit(): void { + let script = Recaptcha.scriptElement; + if (!script) { + window.recaptchaLoaded = () => { + Recaptcha.loaded.next(); + Recaptcha.loaded.complete(); + this.ready.emit(); + } + script = document.createElement('script'); + Recaptcha.scriptElement = script; + const url = new URL('/recaptcha/api.js', 'https://google.com'); + url.searchParams.set('render', this.recaptchaKey()); + url.searchParams.set('onload', 'recaptchaLoaded'); + url.searchParams.set('trustedtypes', 'true'); + + script.src = url.toString(); + script.async = true; + script.defer = true; + script.id = 'recaptcha-script'; + + document.head.appendChild(script); + } else { + this.ready.emit(); + } + } + + ngOnDestroy() { + Recaptcha.instanceCount = Math.max(Recaptcha.instanceCount - 1, 0); + const instanceCount = Recaptcha.instanceCount; + if (instanceCount > 0) return; + document.getElementById('recaptcha-script')?.remove(); + } +} diff --git a/src/app/interceptors/access-token.interceptor.ts b/src/app/interceptors/access-token.interceptor.ts index cb835b7..6935456 100644 --- a/src/app/interceptors/access-token.interceptor.ts +++ b/src/app/interceptors/access-token.interceptor.ts @@ -10,7 +10,7 @@ export const accessTokenInterceptor: HttpInterceptorFn = (req, next) => { const store = inject(Store); const token = store.selectSnapshot(accessToken); - if (req.url.startsWith('/api') && token) { + if (req.url.startsWith(environment.apiOrigin) && token) { return next(req.clone({ setHeaders: { 'Authorization': `Bearer ${token}` } })).pipe( catchError((e: HttpErrorResponse) => { if (e.status == 401 && req.url != environment.apiOrigin + '/auth/revoke-token') diff --git a/src/app/pages/analytics/analytics.component.html b/src/app/pages/analytics/analytics.component.html new file mode 100644 index 0000000..2e09639 --- /dev/null +++ b/src/app/pages/analytics/analytics.component.html @@ -0,0 +1,9 @@ +
+

+ @if(failed()) { + Broken URL. Redirecting to home page... + }@else { + Redirecting... + } +

+
diff --git a/src/app/pages/analytics/analytics.component.scss b/src/app/pages/analytics/analytics.component.scss new file mode 100644 index 0000000..b915a5e --- /dev/null +++ b/src/app/pages/analytics/analytics.component.scss @@ -0,0 +1,3 @@ +:host { + @apply w-full h-full block +} diff --git a/src/app/pages/analytics/analytics.component.ts b/src/app/pages/analytics/analytics.component.ts new file mode 100644 index 0000000..db92248 --- /dev/null +++ b/src/app/pages/analytics/analytics.component.ts @@ -0,0 +1,106 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, computed, inject, linkedSignal, OnInit, viewChild } from '@angular/core'; +import { Recaptcha } from '@app/directives'; +import { principal } from '@app/state/user'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { environment } from '@env/environment.development'; +import { Navigate } from '@ngxs/router-plugin'; +import { dispatch, select } from '@ngxs/store'; +import { injectQueryParams } from 'ngxtension/inject-query-params'; +import { timer } from 'rxjs'; +import { z } from 'zod'; + +const AnalyticsQueryInputSchema = z.object({ + id: z.string().uuid(), + t: z.enum(['broadcast']), + r: z.string() +}) + +@Component({ + selector: 'tm-analytics', + imports: [ + Recaptcha + ], + templateUrl: './analytics.component.html', + styleUrl: './analytics.component.scss' +}) +export class AnalyticsComponent implements OnInit { + private http = inject(HttpClient); + readonly siteKey = environment.recaptchaSiteKey; + private recaptchaV3 = viewChild(Recaptcha); + private navigate = dispatch(Navigate); + private inputParams = injectQueryParams(); + private params = computed(() => { + const { data, error, success } = AnalyticsQueryInputSchema.safeParse(this.inputParams()); + if (!success) { + return undefined; + } + return data; + }); + private principal = select(principal); + readonly failed = linkedSignal(() => { + const { success } = AnalyticsQueryInputSchema.safeParse(this.inputParams()); + return !success; + }) + + async ngOnInit() { + const code = await this.computeDeviceCode(); + console.log(code); + } + + private async computeDeviceCode() { + const { origin } = await fetch('https://httpbin.org/ip').then(res => res.json()); + const parameters = [ + navigator.maxTouchPoints, + window.screen.height, + window.screen.width, + navigator.hardwareConcurrency, + origin + ].join(','); + + const hash = new Sha256(); + hash.update(parameters); + return await hash.digest() + .then(u => Array.from(u) + .map(byte => byte.toString(16).padStart(2, '0')).join('')); + } + + async onRecaptchaReady() { + const token = await this.recaptchaV3()?.getToken('analytics'); + const params = this.params(); + if (!params || !token) { + this.failed.set(true) + // timer(5000).subscribe(() => { + // this.navigate(['/']); + // }); + return; + } + + this.http.post(`${environment.apiOrigin}/analytics`, { + type: params.t, + key: params.id, + data: { + deviceInfo: { + hash: await this.computeDeviceCode(), + height: window.screen.height, + width: window.screen.width, + concurrency: navigator.hardwareConcurrency, + } + } + }, { + headers: { + 'x-recaptcha': token, + } + }).subscribe({ + complete: () => { + location.href = params.r; + }, + error: () => { + this.failed.set(true); + timer(5000).subscribe(() => { + this.navigate(['/']); + }) + } + }) + } +} diff --git a/src/app/pages/wallet/wallet.component.html b/src/app/pages/wallet/wallet.component.html index f1636b6..b035743 100644 --- a/src/app/pages/wallet/wallet.component.html +++ b/src/app/pages/wallet/wallet.component.html @@ -36,59 +36,81 @@

-
+
@let totalRecords = transfers.value()?.total ?? 0; + [value]="transfers.value()?.groups ?? []"> + # of Credits + # of Transactions + Date + + + + + + {{ transaction.transferredCredits | number }} + {{ transaction.transfers.length | number }} + {{ transaction.burst | date }} + + + + + + + + + + Credits Status Type - Date + Payment + Time + - -
-
-

Transactions

- @if(transfers.isLoading()) { - - } -
-
-
- + - -
- @if(transaction.amount === undefined || transaction.amount == null) { -

- Unavailable -

- } @else { - {{ transaction.amount | number }} - @if(transaction.payment){ -
- - {{ transaction.payment.amount | - currency:transaction.payment.currency:'symbol' }} - - {{ transaction.payment.status }} -
- } - } + + {{ transfer.credits | number }} + {{ transfer.status }} + {{ transfer.type }} + @if(transfer.payment){ +
+ + {{ transfer.payment.amount | + currency:transfer.payment.currency:'symbol' }} + + {{ transfer.payment.status }}
+ } @else { + N/A + } - {{ transaction.status }} - {{ transaction.type }} - {{ transaction.date | date:'dd/MM/yy, HH:mm' }} + {{ transfer.recordedAt | date:'HH:mm' }} + + + + +
+
+

Transfers

+ @if(transfers.isLoading()) { + + } +
+
+
+
diff --git a/src/app/pages/wallet/wallet.component.ts b/src/app/pages/wallet/wallet.component.ts index a65d3c7..778e5e8 100644 --- a/src/app/pages/wallet/wallet.component.ts +++ b/src/app/pages/wallet/wallet.component.ts @@ -99,8 +99,9 @@ export class WalletComponent { transferTypeText(type: 'funding' | 'reward' | 'withdrawal') { switch (type) { - case 'funding': return 'warn'; - case 'reward': return 'success'; + case 'funding': return 'success'; + case 'withdrawal': + case 'reward': return 'warn'; default: return 'secondary'; } }