Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
FROM node:20-alpine
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate

# Claude Code CLI (used by api-server LLM signal flow). Pure Node package, no
# native deps, so it installs cleanly on alpine.
RUN npm install -g @anthropic-ai/claude-code && claude --version

WORKDIR /app

# Install all monorepo dependencies
Expand Down
39 changes: 39 additions & 0 deletions apps/api-server/src/activity/activity.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ActivityService } from './activity.service';

const mockPrisma = {
order: { findMany: vi.fn() },
loginHistory: { findMany: vi.fn() },
};

describe('ActivityService', () => {
let svc: ActivityService;

beforeEach(() => {
vi.clearAllMocks();
svc = new ActivityService(mockPrisma as never);
});

it('주문 활동 항목은 /orders/${id} 로 링크된다', async () => {
mockPrisma.order.findMany.mockResolvedValue([
{
id: 'order-abc',
side: 'long',
symbol: 'BTCUSDT',
type: 'market',
quantity: '0.01',
filledPrice: '60000',
price: null,
mode: 'real',
exchange: 'binance',
status: 'filled',
createdAt: new Date('2026-04-01T00:00:00Z'),
},
]);
mockPrisma.loginHistory.findMany.mockResolvedValue([]);

const { items } = await svc.getActivity('user-1');
const orderItem = items.find((i) => i.type === 'order');
expect(orderItem?.link).toBe('/orders/order-abc');
});
});
46 changes: 34 additions & 12 deletions apps/api-server/src/activity/activity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ export interface ActivityItem {
createdAt: Date;
}

function formatCloseReason(reason: string): string {
switch (reason) {
case 'take_profit':
return 'TP 익절';
case 'stop_loss':
return 'SL 손절';
case 'liquidation':
return '청산';
case 'manual':
return '수동 종료';
case 'manual_on_exchange':
return '거래소에서 종료';
case 'reconciled_unknown':
return '동기화';
default:
return reason;
}
}

@Injectable()
export class ActivityService {
constructor(private readonly prisma: PrismaService) {}
Expand Down Expand Up @@ -44,18 +63,21 @@ export class ActivityService {
}),
]);

const orderItems: ActivityItem[] = orders.map((o) => ({
id: `order-${o.id}`,
type: 'order' as const,
title: `${o.side.toUpperCase()} ${o.symbol}`,
description: `${o.type} ${o.quantity} @ ${o.filledPrice !== '0' ? o.filledPrice : o.price || 'market'} (${o.mode})`,
exchange: o.exchange,
symbol: o.symbol,
status: o.status,
side: o.side,
link: '/orders',
createdAt: o.createdAt,
}));
const orderItems: ActivityItem[] = orders.map((o) => {
const reasonSuffix = o.closeReason ? ` · ${formatCloseReason(o.closeReason)}` : '';
return {
id: `order-${o.id}`,
type: 'order' as const,
title: `${o.side.toUpperCase()} ${o.symbol}`,
description: `${o.type} ${o.quantity} @ ${o.filledPrice !== '0' ? o.filledPrice : o.price || 'market'} (${o.mode})${reasonSuffix}`,
exchange: o.exchange,
symbol: o.symbol,
status: o.closedAt ? 'closed' : o.status,
side: o.side,
link: `/orders/${o.id}`,
createdAt: o.createdAt,
};
});

const loginItems: ActivityItem[] = logins.map((l) => ({
id: `login-${l.id}`,
Expand Down
8 changes: 8 additions & 0 deletions apps/api-server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import { OrdersModule } from './orders/orders.module';
import { NotificationsModule } from './notifications/notifications.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { ActivityModule } from './activity/activity.module';
import { ClaudeTokensModule } from './claude-tokens/claude-tokens.module';
import { LlmTradesModule } from './llm-trades/llm-trades.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { DebugModule } from './debug/debug.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';

@Module({
Expand Down Expand Up @@ -48,6 +52,10 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
NotificationsModule,
PortfolioModule,
ActivityModule,
ClaudeTokensModule,
LlmTradesModule,
DashboardModule,
DebugModule,
],
controllers: [AppController],
providers: [
Expand Down
33 changes: 33 additions & 0 deletions apps/api-server/src/claude-tokens/claude-tokens.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Body, Controller, Delete, Get, HttpCode, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { ClaudeTokensService } from './claude-tokens.service';
import { SaveClaudeTokenDto } from './dto/save-claude-token.dto';

@ApiTags('Claude Tokens')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('claude-tokens')
export class ClaudeTokensController {
constructor(private readonly service: ClaudeTokensService) {}

@Get()
@ApiOperation({ summary: 'Check if a Claude OAuth token is registered for the user' })
status(@CurrentUser() user: { id: string }) {
return this.service.getStatus(user.id);
}

@Post()
@ApiOperation({ summary: 'Save / replace the user Claude OAuth token (encrypted at rest)' })
save(@CurrentUser() user: { id: string }, @Body() dto: SaveClaudeTokenDto) {
return this.service.save(user.id, dto.token);
}

@Delete()
@ApiOperation({ summary: 'Delete the user Claude OAuth token' })
@HttpCode(204)
async remove(@CurrentUser() user: { id: string }) {
await this.service.delete(user.id);
}
}
12 changes: 12 additions & 0 deletions apps/api-server/src/claude-tokens/claude-tokens.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ClaudeTokensController } from './claude-tokens.controller';
import { ClaudeTokensService } from './claude-tokens.service';

@Module({
imports: [PrismaModule],
controllers: [ClaudeTokensController],
providers: [ClaudeTokensService],
exports: [ClaudeTokensService],
})
export class ClaudeTokensModule {}
49 changes: 49 additions & 0 deletions apps/api-server/src/claude-tokens/claude-tokens.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { encrypt, decrypt } from '@coin/utils';

@Injectable()
export class ClaudeTokensService {
private readonly logger = new Logger(ClaudeTokensService.name);

constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}

private get masterKey(): string {
return this.config.getOrThrow<string>('ENCRYPTION_MASTER_KEY');
}

async getStatus(userId: string): Promise<{ registered: boolean; updatedAt?: Date }> {
const row = await this.prisma.claudeToken.findUnique({ where: { userId } });
if (!row) return { registered: false };
return { registered: true, updatedAt: row.updatedAt };
}

async save(userId: string, token: string): Promise<{ updatedAt: Date }> {
const encrypted = encrypt(token, this.masterKey);
const row = await this.prisma.claudeToken.upsert({
where: { userId },
update: { encryptedToken: encrypted },
create: { userId, encryptedToken: encrypted },
});
this.logger.log(`Claude token saved for user ${userId}`);
return { updatedAt: row.updatedAt };
}

async delete(userId: string): Promise<void> {
await this.prisma.claudeToken.delete({ where: { userId } }).catch(() => {
throw new NotFoundException('Claude token not registered');
});
this.logger.log(`Claude token deleted for user ${userId}`);
}

/** Decrypt and return the raw OAuth token. Use only at LLM call time. */
async getDecrypted(userId: string): Promise<string> {
const row = await this.prisma.claudeToken.findUnique({ where: { userId } });
if (!row) throw new NotFoundException('Claude token not registered');
return decrypt(row.encryptedToken, this.masterKey);
}
}
12 changes: 12 additions & 0 deletions apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class SaveClaudeTokenDto {
@ApiProperty({
description: 'Claude Code OAuth long-lived token (run `claude setup-token`)',
example: 'sk-ant-oat01-...',
})
@IsString()
@MinLength(20)
token!: string;
}
19 changes: 19 additions & 0 deletions apps/api-server/src/dashboard/dashboard.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { DashboardService } from './dashboard.service';

@ApiTags('Dashboard')
@ApiBearerAuth('access-token')
@Controller('dashboard')
export class DashboardController {
constructor(private readonly service: DashboardService) {}

@Get('summary')
@ApiOperation({
summary: '대시보드 단일 집계 (오늘/이번주 PnL · 활성 포지션 · 최근 LLM 결정)',
})
summary(@CurrentUser() user: { id: string }) {
return this.service.getSummary(user.id);
}
}
11 changes: 11 additions & 0 deletions apps/api-server/src/dashboard/dashboard.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';

@Module({
imports: [PrismaModule],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}
Loading
Loading