feat: add AccountsModule with list and QR endpoints

Implements GET /accounts (list all accounts) and GET /accounts/:id/qr
(returns QR code as base64 data URL) using the qrcode package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 11:15:42 +05:30
parent 02dad1347c
commit 1dba77959d
8 changed files with 268 additions and 0 deletions
+2
View File
@@ -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",
+2
View File
@@ -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 {}
@@ -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>(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');
});
});
@@ -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);
}
}
@@ -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 {}
@@ -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>(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 });
});
});
});
@@ -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<AccountSummary[]> {
return this.prisma.account.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, platform: true, jid: true, displayName: true, status: true },
});
}
async getQr(id: string): Promise<AccountQr> {
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 };
}
}
+97
View File
@@ -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