diff --git a/apps/api/package.json b/apps/api/package.json index 8269c03..f6dc5aa 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "@tower/logger": "workspace:*", "@tower/search": "workspace:*", "@tower/types": "workspace:*", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0" }, @@ -27,6 +28,7 @@ "@nestjs/testing": "^11.0.0", "@types/jest": "^29.0.0", "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", "dotenv": "^17.4.2", "jest": "^29.0.0", "prisma": "^6.0.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 0e9c473..d2e36d8 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ import { HealthModule } from './modules/health/health.module'; import { SearchModule } from './modules/search/search.module'; import { GroupsModule } from './modules/groups/groups.module'; import { RoutesModule } from './modules/routes/routes.module'; +import { AccountsModule } from './modules/accounts/accounts.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { RoutesModule } from './modules/routes/routes.module'; SearchModule, GroupsModule, RoutesModule, + AccountsModule, ], }) export class AppModule {} diff --git a/apps/api/src/modules/accounts/accounts.controller.spec.ts b/apps/api/src/modules/accounts/accounts.controller.spec.ts new file mode 100644 index 0000000..13fb2bd --- /dev/null +++ b/apps/api/src/modules/accounts/accounts.controller.spec.ts @@ -0,0 +1,36 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AccountsController } from './accounts.controller'; +import { AccountsService } from './accounts.service'; + +const mockAccounts = [ + { id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test', status: 'ACTIVE' }, +]; +const mockService = { + list: jest.fn().mockResolvedValue(mockAccounts), + getQr: jest.fn().mockResolvedValue({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fake' }), +}; + +describe('AccountsController', () => { + let controller: AccountsController; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + controllers: [AccountsController], + providers: [{ provide: AccountsService, useValue: mockService }], + }).compile(); + controller = module.get(AccountsController); + }); + + it('list() returns accounts from service', async () => { + const result = await controller.list(); + expect(result).toEqual(mockAccounts); + expect(mockService.list).toHaveBeenCalled(); + }); + + it('getQr() calls service with the account id', async () => { + const result = await controller.getQr('acc_1'); + expect(mockService.getQr).toHaveBeenCalledWith('acc_1'); + expect(result.qrDataUrl).toBe('data:image/png;base64,fake'); + }); +}); diff --git a/apps/api/src/modules/accounts/accounts.controller.ts b/apps/api/src/modules/accounts/accounts.controller.ts new file mode 100644 index 0000000..411e2dd --- /dev/null +++ b/apps/api/src/modules/accounts/accounts.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { AccountsService } from './accounts.service'; + +@Controller('accounts') +export class AccountsController { + constructor(private readonly service: AccountsService) {} + + @Get() + list() { + return this.service.list(); + } + + @Get(':id/qr') + getQr(@Param('id') id: string) { + return this.service.getQr(id); + } +} diff --git a/apps/api/src/modules/accounts/accounts.module.ts b/apps/api/src/modules/accounts/accounts.module.ts new file mode 100644 index 0000000..0e18141 --- /dev/null +++ b/apps/api/src/modules/accounts/accounts.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AccountsController } from './accounts.controller'; +import { AccountsService } from './accounts.service'; + +@Module({ + controllers: [AccountsController], + providers: [AccountsService], +}) +export class AccountsModule {} diff --git a/apps/api/src/modules/accounts/accounts.service.spec.ts b/apps/api/src/modules/accounts/accounts.service.spec.ts new file mode 100644 index 0000000..1ecc065 --- /dev/null +++ b/apps/api/src/modules/accounts/accounts.service.spec.ts @@ -0,0 +1,66 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AccountsService } from './accounts.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import * as QRCode from 'qrcode'; + +jest.mock('qrcode', () => ({ + toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,fakedata'), +})); + +const mockAccounts = [ + { id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test Account', status: 'ACTIVE' }, +]; + +const mockPrisma = { + account: { + findMany: jest.fn().mockResolvedValue(mockAccounts), + findUnique: jest.fn(), + }, +}; + +describe('AccountsService', () => { + let service: AccountsService; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AccountsService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + service = module.get(AccountsService); + }); + + describe('list()', () => { + it('returns accounts from Prisma without qrCode field', async () => { + const result = await service.list(); + expect(result).toEqual(mockAccounts); + expect(mockPrisma.account.findMany).toHaveBeenCalledWith( + expect.objectContaining({ select: expect.not.objectContaining({ qrCode: true }) }), + ); + }); + }); + + describe('getQr()', () => { + it('returns null qrDataUrl when account has no qrCode', async () => { + mockPrisma.account.findUnique.mockResolvedValue({ status: 'ACTIVE', qrCode: null }); + const result = await service.getQr('acc_1'); + expect(result).toEqual({ status: 'ACTIVE', qrDataUrl: null }); + expect(QRCode.toDataURL).not.toHaveBeenCalled(); + }); + + it('converts qrCode string to data URL when qrCode is present', async () => { + mockPrisma.account.findUnique.mockResolvedValue({ status: 'DISCONNECTED', qrCode: 'raw-qr-string' }); + const result = await service.getQr('acc_1'); + expect(QRCode.toDataURL).toHaveBeenCalledWith('raw-qr-string'); + expect(result).toEqual({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fakedata' }); + }); + + it('returns not_found status when account does not exist', async () => { + mockPrisma.account.findUnique.mockResolvedValue(null); + const result = await service.getQr('nonexistent'); + expect(result).toEqual({ status: 'not_found', qrDataUrl: null }); + }); + }); +}); diff --git a/apps/api/src/modules/accounts/accounts.service.ts b/apps/api/src/modules/accounts/accounts.service.ts new file mode 100644 index 0000000..47706ca --- /dev/null +++ b/apps/api/src/modules/accounts/accounts.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import * as QRCode from 'qrcode'; + +export interface AccountSummary { + id: string; + platform: string; + jid: string; + displayName: string | null; + status: string; +} + +export interface AccountQr { + status: string; + qrDataUrl: string | null; +} + +@Injectable() +export class AccountsService { + constructor(private readonly prisma: PrismaService) {} + + list(): Promise { + return this.prisma.account.findMany({ + orderBy: { createdAt: 'asc' }, + select: { id: true, platform: true, jid: true, displayName: true, status: true }, + }); + } + + async getQr(id: string): Promise { + const account = await this.prisma.account.findUnique({ + where: { id }, + select: { status: true, qrCode: true }, + }); + if (!account) return { status: 'not_found', qrDataUrl: null }; + if (!account.qrCode) return { status: account.status, qrDataUrl: null }; + const qrDataUrl = await QRCode.toDataURL(account.qrCode); + return { status: account.status, qrDataUrl }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2f33bb..a6a28ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@tower/types': specifier: workspace:* version: link:../../packages/types + qrcode: + specifier: ^1.5.4 + version: 1.5.4 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -72,6 +75,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.19 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -1369,6 +1375,9 @@ packages: '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1766,6 +1775,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1915,6 +1927,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1975,6 +1991,9 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3078,6 +3097,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -3149,6 +3172,11 @@ packages: resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -3224,6 +3252,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -3312,6 +3343,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3750,6 +3784,9 @@ packages: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} engines: {node: '>=12'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3795,6 +3832,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3802,10 +3842,18 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -4980,6 +5028,10 @@ snapshots: '@types/qrcode-terminal@0.12.2': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.19 + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: '@types/react': 19.2.15 @@ -5437,6 +5489,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -5566,6 +5624,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} dedent@1.7.2: {} @@ -5598,6 +5658,8 @@ snapshots: diff@4.0.4: {} + dijkstrajs@1.0.3: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -6915,6 +6977,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.12 @@ -6998,6 +7062,12 @@ snapshots: qrcode-terminal@0.12.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -7066,6 +7136,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} resolve-cwd@3.0.0: @@ -7166,6 +7238,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} sharp@0.34.5: @@ -7591,6 +7665,8 @@ snapshots: tr46: 3.0.0 webidl-conversions: 7.0.0 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7624,12 +7700,33 @@ snapshots: xmlchars@2.2.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1