diff --git a/apps/web/.env.example b/apps/web/.env.example index c9f6a90..3c2f5b9 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,4 +1,8 @@ # Admin Web server only. SvelteKit reads apps/web/.env when run from apps/web/. # Docker Compose passes BACKEND_INTERNAL_URL directly to the web container instead. BACKEND_INTERNAL_URL=http://127.0.0.1:8000 +# Label for this Web instance: development, test, or production. Set test when using ai_platform_test. +WEB_ENVIRONMENT=development +# Optional server-only key for the internal fixture runner (use a separate test tenant). +# LEAD_SCORING_FIXTURE_TENANT_KEY= # Never put credentials or backend URLs in PUBLIC_* variables. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index e112990..4cc0fea 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -5,6 +5,7 @@ COPY apps/web/package.json apps/web/bun.lock ./ RUN bun install --frozen-lockfile COPY apps/web/ ./ COPY contracts/lead-scoring/v2/openapi.json /contracts/lead-scoring/v2/openapi.json +COPY contracts/lead-scoring/v2/fixtures/run-request.json /contracts/lead-scoring/v2/fixtures/run-request.json RUN bun run build FROM oven/bun:1.4.0 AS runtime diff --git a/apps/web/src/lib/components/AdminNavigation.svelte b/apps/web/src/lib/components/AdminNavigation.svelte index 2184a74..47f4b50 100644 --- a/apps/web/src/lib/components/AdminNavigation.svelte +++ b/apps/web/src/lib/components/AdminNavigation.svelte @@ -1,11 +1,11 @@ {#snippet versionList()} @@ -24,9 +29,14 @@ {#each data.profiles as profile (profile.version)}
  • {profile.version} + {providerLabels[profile.provider]} {profile.model} + {#if profile.base_url}{profile.base_url}{/if} {#if profile.active}Đang dùng{/if} @@ -42,9 +52,7 @@

    Tạo và kích hoạt phiên bản

    - Tạo phiên bản mới thay vì sửa lịch sử của các lần chấm điểm đã chạy. + Mỗi lần đổi cấu hình, tạo phiên bản mới.
    @@ -58,11 +66,38 @@ />
    - Nhà cung cấp + +
    + {#if selectedProvider === 'openai_compatible'} +
    + + +
    + {/if} +
    +
    @@ -79,7 +114,7 @@ name="timeout_seconds" type="number" min="1" - max="60" + max="120" value="12" required /> @@ -100,8 +135,7 @@

    Lead Scoring / Cấu hình

    Cấu hình AI

    - Quản lý model, prompt và thời gian chờ của AI Platform. Omni sở hữu quy tắc và trọng số; - thông tin xác thực nhà cung cấp, gồm V-Key, do AIHub quản lý và không nhập tại đây. + Chọn nhà cung cấp, nhập URL nếu dùng OpenAI-compatible và chọn model cho Lead Scoring.

    diff --git a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/ai-profiles.test.ts b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/ai-profiles.test.ts new file mode 100644 index 0000000..b48402d --- /dev/null +++ b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/ai-profiles.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { backend } from '$lib/server/backend'; +import { actions } from './+page.server'; + +vi.mock('$lib/server/backend', () => ({ backend: vi.fn(), backendJson: vi.fn() })); + +const backendMock = vi.mocked(backend); +type ActionEvent = Parameters>[0]; + +describe('AI profile provider selection', () => { + it('sends only a selected provider and profile settings to backend', async () => { + const form = new FormData(); + form.set('version', 'profile-v2'); + form.set('provider', 'openai_compatible'); + form.set('base_url', 'http://127.0.0.1:8317'); + form.set('model', 'gemini-3.5-flash-lite'); + form.set('system_prompt', 'Only use provided evidence.'); + form.set('timeout_seconds', '30'); + const event = { + request: { formData: async () => form }, + } as unknown as ActionEvent; + backendMock.mockResolvedValueOnce(new Response('{}', { status: 200 })); + const action = actions.default; + if (!action) throw new Error('AI profile action missing'); + expect(await action(event)).toMatchObject({ success: true }); + expect(backendMock).toHaveBeenCalledWith( + event, + '/v1/admin/ai-profiles', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + version: 'profile-v2', + provider: 'openai_compatible', + base_url: 'http://127.0.0.1:8317', + model: 'gemini-3.5-flash-lite', + system_prompt: 'Only use provided evidence.', + timeout_seconds: 30, + }), + }), + ); + expect(backendMock.mock.calls[0][2]?.body).not.toContain('api_key'); + }); +}); diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts new file mode 100644 index 0000000..d632d38 --- /dev/null +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts @@ -0,0 +1,64 @@ +import { fail } from '@sveltejs/kit'; +import { backend, clearAdminSession } from '$lib/server/backend'; +import { + fixtureRunnerConfigured, + FixtureRunnerNotConfigured, + submitScoringFixture, +} from '$lib/server/fixture-runner'; +import type { components } from '$lib/generated/api'; +import type { Actions, PageServerLoad } from './$types'; + +type RunSummary = components['schemas']['RunSummary']; + +export const load: PageServerLoad = async () => ({ + fixtureRunnerConfigured: fixtureRunnerConfigured(), +}); + +export const actions: Actions = { + default: async (event) => { + event.setHeaders({ 'cache-control': 'no-store' }); + const adminSession = await backend(event, '/v1/admin/me'); + if (adminSession.status === 401) { + clearAdminSession(event); + return fail(401, { message: 'Phiên quản trị đã hết hạn. Hãy đăng nhập lại.' }); + } + if (!adminSession.ok) { + return fail(502, { message: 'Không thể xác minh phiên quản trị.' }); + } + if (!fixtureRunnerConfigured()) { + return fail(503, { + message: 'Chưa có mã truy cập cho mẫu này trên server.', + }); + } + + let response: Response; + try { + response = await submitScoringFixture(event); + } catch (error) { + if (error instanceof FixtureRunnerNotConfigured) { + return fail(503, { + message: 'Chưa có mã truy cập cho mẫu này trên server.', + }); + } + return fail(502, { message: 'Không gửi được dữ liệu mẫu.' }); + } + if (response.status === 401) { + return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.' }); + } + if (!response.ok) { + return fail(response.status, { message: 'Không nhận được dữ liệu mẫu.' }); + } + + try { + const run = (await response.json()) as RunSummary; + return { + success: true, + run_id: run.run_id, + status: run.status, + message: 'Đã gửi dữ liệu mẫu.', + }; + } catch { + return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.' }); + } + }, +}; diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte new file mode 100644 index 0000000..5cb5d1e --- /dev/null +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte @@ -0,0 +1,48 @@ + + +Thử quy tắc · AI Platform +
    +

    Lead Scoring / Kiểm thử

    +

    Thử tính điểm theo quy tắc

    +

    + Gửi dữ liệu mẫu để kiểm tra điểm quy tắc. Trang này chưa thử model AI. +

    +
    + + +

    Dữ liệu mẫu

    +
    + + {#if !data.fixtureRunnerConfigured} +

    + Chưa có mã truy cập cho mẫu này trên server. +

    + {/if} + {#if form?.message} +

    + {form.message} +

    + {/if} + {#if form?.success && form.run_id} +

    + Trạng thái: {form.status}. + Xem lần chấm điểm +

    + {/if} + + + +
    +
    diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts new file mode 100644 index 0000000..b961565 --- /dev/null +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { backend, clearAdminSession } from '$lib/server/backend'; +import { fixtureRunnerConfigured, submitScoringFixture } from '$lib/server/fixture-runner'; +import { actions, load } from './+page.server'; + +vi.mock('$lib/server/backend', () => ({ + backend: vi.fn(), + clearAdminSession: vi.fn(), +})); +vi.mock('$lib/server/fixture-runner', () => ({ + fixtureRunnerConfigured: vi.fn(), + submitScoringFixture: vi.fn(), + FixtureRunnerNotConfigured: class extends Error {}, +})); + +const backendMock = vi.mocked(backend); +const configuredMock = vi.mocked(fixtureRunnerConfigured); +const submitMock = vi.mocked(submitScoringFixture); +const clearSessionMock = vi.mocked(clearAdminSession); +const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b'; + +type ActionEvent = Parameters>[0]; + +function eventFor(): { event: ActionEvent; setHeaders: ReturnType } { + const setHeaders = vi.fn(); + return { event: { setHeaders } as unknown as ActionEvent, setHeaders }; +} + +beforeEach(() => { + vi.clearAllMocks(); + configuredMock.mockReturnValue(true); + backendMock.mockResolvedValue(new Response('{}', { status: 200 })); +}); + +describe('fixture runner page', () => { + it('shows whether a server-only fixture key is configured', async () => { + configuredMock.mockReturnValue(false); + const page = await load({} as Parameters[0]); + expect(page).toEqual({ fixtureRunnerConfigured: false }); + }); + + it('submits the fixture only after checking the admin session', async () => { + submitMock.mockResolvedValueOnce( + Response.json({ run_id: runId, status: 'pending' }, { status: 202 }), + ); + const action = actions.default; + if (!action) throw new Error('fixture action missing'); + const { event, setHeaders } = eventFor(); + const result = await action(event); + expect(result).toMatchObject({ success: true, run_id: runId, status: 'pending' }); + expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); + expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me'); + expect(submitMock).toHaveBeenCalledWith(event); + expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan( + submitMock.mock.invocationCallOrder[0], + ); + }); + + it('rejects an expired admin session without submitting', async () => { + backendMock.mockResolvedValueOnce(new Response('{}', { status: 401 })); + const action = actions.default; + if (!action) throw new Error('fixture action missing'); + const { event } = eventFor(); + const result = await action(event); + expect(result).toMatchObject({ status: 401 }); + expect(clearSessionMock).toHaveBeenCalledWith(event); + expect(submitMock).not.toHaveBeenCalled(); + }); + + it('rejects an unconfigured fixture key without submitting', async () => { + configuredMock.mockReturnValue(false); + const action = actions.default; + if (!action) throw new Error('fixture action missing'); + const result = await action(eventFor().event); + expect(result).toMatchObject({ status: 503 }); + expect(submitMock).not.toHaveBeenCalled(); + }); + + it('does not expose a rejected tenant key in the response', async () => { + submitMock.mockResolvedValueOnce(new Response('{}', { status: 401 })); + const action = actions.default; + if (!action) throw new Error('fixture action missing'); + const result = await action(eventFor().event); + expect(result).toMatchObject({ status: 502 }); + expect(JSON.stringify(result)).not.toContain('Bearer'); + }); +}); diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts new file mode 100644 index 0000000..2aa29cd --- /dev/null +++ b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts @@ -0,0 +1,84 @@ +import { error, fail } from '@sveltejs/kit'; +import { backendJson } from '$lib/server/backend'; +import type { components } from '$lib/generated/api'; +import type { Actions, PageServerLoad } from './$types'; + +type TenantKeySummary = components['schemas']['TenantKeySummary']; +type TenantKeyList = components['schemas']['TenantKeyList']; +type TenantKeySecret = components['schemas']['TenantKeySecret']; + +export const load: PageServerLoad = async (event) => { + const { response, data } = await backendJson(event, '/v1/admin/tenant-keys'); + if (!response.ok || !data) error(502, 'Không tải được danh sách tenant key.'); + return { tenants: data.tenants }; +}; + +function mutationMessage(status: number, action: 'create' | 'rotate' | 'revoke'): string { + if (status === 409 && action === 'create') + return 'Tenant này đã có key. Hãy xoay vòng key hiện tại.'; + if (status === 404) return 'Không tìm thấy tenant key.'; + if (status === 422) return 'Tenant ID không hợp lệ.'; + if (status === 401 || status === 403) return 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.'; + return 'Không thể cập nhật tenant key. Vui lòng thử lại.'; +} + +export const actions: Actions = { + create: async (event) => { + event.setHeaders({ 'cache-control': 'no-store' }); + const form = await event.request.formData(); + const tenant_id = String(form.get('tenant_id') ?? '').trim(); + if (!tenant_id || tenant_id.length > 128) { + return fail(400, { message: 'Nhập Tenant ID từ 1 đến 128 ký tự.' }); + } + const { response, data } = await backendJson( + event, + '/v1/admin/tenant-keys', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tenant_id }), + }, + ); + if (!response.ok || !data) { + return fail(response.status, { message: mutationMessage(response.status, 'create') }); + } + return { + success: true, + message: 'Đã tạo tenant key.', + tenant_id: data.tenant_id, + tenant_key: data.tenant_key, + }; + }, + rotate: async (event) => { + event.setHeaders({ 'cache-control': 'no-store' }); + const form = await event.request.formData(); + const tenant_id = String(form.get('tenant_id') ?? ''); + if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' }); + const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}/rotate` as const; + const { response, data } = await backendJson(event, path, { + method: 'POST', + }); + if (!response.ok || !data) { + return fail(response.status, { message: mutationMessage(response.status, 'rotate') }); + } + return { + success: true, + message: `Đã xoay vòng key cho ${data.tenant_id}.`, + tenant_id: data.tenant_id, + tenant_key: data.tenant_key, + }; + }, + revoke: async (event) => { + const form = await event.request.formData(); + const tenant_id = String(form.get('tenant_id') ?? ''); + if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' }); + const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}` as const; + const { response, data } = await backendJson(event, path, { + method: 'DELETE', + }); + if (!response.ok || !data) { + return fail(response.status, { message: mutationMessage(response.status, 'revoke') }); + } + return { success: true, message: `Đã thu hồi key của ${data.tenant_id}.` }; + }, +}; diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte new file mode 100644 index 0000000..2947155 --- /dev/null +++ b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte @@ -0,0 +1,159 @@ + + +Mã truy cập · AI Platform +
    +

    Platform / Truy cập

    +

    Mã truy cập của tổ chức

    +

    + Tạo mã cho tổ chức gửi yêu cầu chấm điểm. +

    +
    + +{#if form?.message} +

    + {form.message} +

    +{/if} + +{#if form?.tenant_key} +
    +

    Sao chép mã vừa tạo

    +

    + Sao chép mã này. Nếu làm mất, hãy dùng “Thay mã” ở danh sách bên dưới. +

    +
    + {form.tenant_key} + +
    + {#if copied}

    Đã sao chép mã.

    {/if} + {#if copyError}{/if} +
    +{/if} + +
    + + +

    Tạo mã truy cập

    + Dành cho tổ chức chưa có mã. +
    + +
    + + + +
    +
    +
    + + + +

    Tổ chức đã có mã

    +
    + + {#if data.tenants.length === 0} +

    Chưa có tổ chức nào được cấp mã.

    + {:else} +
      + {#each data.tenants as tenant (tenant.tenant_id)} +
    • +
      + {tenant.tenant_id} + + {tenant.active ? 'Đang hoạt động' : 'Đã thu hồi'} + +
      +

      + Tạo {new Date(tenant.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date( + tenant.updated_at, + ).toLocaleString('vi-VN')} +

      +
      +
      + confirmAction(event, 'rotate', tenant.tenant_id)} + > + + +
      + {#if tenant.active} +
      + confirmAction(event, 'revoke', tenant.tenant_id)} + > + + +
      + {/if} +
      +
    • + {/each} +
    + {/if} +
    +
    +
    diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts b/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts new file mode 100644 index 0000000..93acaaf --- /dev/null +++ b/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { backend, backendJson } from '$lib/server/backend'; +import { actions } from './+page.server'; + +type TenantKeyRequestEvent = Parameters>[0]; + +vi.mock('$lib/server/backend', () => ({ + backend: vi.fn(), + backendJson: vi.fn(), +})); + +function eventFor(values: Record): { + event: TenantKeyRequestEvent; + setHeaders: ReturnType; +} { + const setHeaders = vi.fn(); + return { + event: { + request: new Request('http://localhost/platform/tenant-keys', { + method: 'POST', + body: new URLSearchParams(values), + }), + setHeaders, + } as unknown as TenantKeyRequestEvent, + setHeaders, + }; +} + +const backendJsonMock = vi.mocked(backendJson); +const backendMock = vi.mocked(backend); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('tenant key server actions', () => { + it('returns a newly-created key only in the action result', async () => { + backendJsonMock.mockResolvedValueOnce({ + response: { ok: true, status: 201 } as Response, + data: { tenant_id: 'tenant-a', tenant_key: 'tnt-once' }, + }); + const action = actions.create; + if (!action) throw new Error('create action missing'); + const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' }); + const result = await action(event); + expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-once' }); + expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); + expect(backendJsonMock).toHaveBeenCalledWith( + expect.anything(), + '/v1/admin/tenant-keys', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('rotates a key through the Admin API and returns the new value once', async () => { + backendJsonMock.mockResolvedValueOnce({ + response: { ok: true, status: 200 } as Response, + data: { tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' }, + }); + const action = actions.rotate; + if (!action) throw new Error('rotate action missing'); + const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' }); + const result = await action(event); + expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' }); + expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); + expect(backendJsonMock).toHaveBeenCalledWith( + expect.anything(), + '/v1/admin/tenant-keys/tenant-a/rotate', + { method: 'POST' }, + ); + }); + + it('reports an existing tenant conflict without returning a key', async () => { + backendJsonMock.mockResolvedValueOnce({ + response: { ok: false, status: 409 } as Response, + data: null, + }); + const action = actions.create; + if (!action) throw new Error('create action missing'); + const result = await action(eventFor({ tenant_id: 'tenant-a' }).event); + expect(result).toMatchObject({ + status: 409, + data: { message: expect.stringContaining('đã có key') }, + }); + }); + + it('sends revoke only through the backend server helper', async () => { + backendJsonMock.mockResolvedValueOnce({ + response: { ok: true, status: 200 } as Response, + data: { + tenant_id: 'tenant/a', + active: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + }, + }); + const action = actions.revoke; + if (!action) throw new Error('revoke action missing'); + const result = await action(eventFor({ tenant_id: 'tenant/a' }).event); + expect(result).toMatchObject({ success: true }); + expect(backendJsonMock).toHaveBeenCalledWith( + expect.anything(), + '/v1/admin/tenant-keys/tenant%2Fa', + { method: 'DELETE' }, + ); + expect(backendMock).not.toHaveBeenCalled(); + }); +});