diff --git a/apps/api/src/handlers/documents/list/handler.ts b/apps/api/src/handlers/documents/list/handler.ts index 3183f94..1da7b25 100644 --- a/apps/api/src/handlers/documents/list/handler.ts +++ b/apps/api/src/handlers/documents/list/handler.ts @@ -25,7 +25,7 @@ export const handler: LambdaHandler = withSecrets( } const repo = new DocumentsRepo(ctx.orgId); - const { rows, nextCursor } = await repo.list(parsed.data.limit, parsed.data.cursor); + const { rows, nextCursor } = await repo.list(parsed.data); const body: DocumentListResponse = { documents: rows.map(toDocument), nextCursor }; return jsonResponse(200, body); }), diff --git a/apps/api/src/handlers/documents/search.integration.spec.ts b/apps/api/src/handlers/documents/search.integration.spec.ts new file mode 100644 index 0000000..a0f2c7a --- /dev/null +++ b/apps/api/src/handlers/documents/search.integration.spec.ts @@ -0,0 +1,170 @@ +/** + * Search / filter / stats integration tests against a real Postgres. + * Same gate as the other suites (RUN_INTEGRATION=1 + localhost). No S3 or AI — + * documents are inserted directly so we can assert the query behaviour. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { APIGatewayProxyEventV2, Context } from 'aws-lambda'; + +const RUN = process.env['RUN_INTEGRATION'] === '1'; +const INTEGRATION_URL = + process.env['INTEGRATION_DATABASE_URL'] ?? + 'postgres://clouddocs:clouddocs@localhost:5434/clouddocs'; +const LOCAL_HOST_RE = /(?:^|@|\/\/)(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::|\/|$)/; +const URL_LOCAL = LOCAL_HOST_RE.test(INTEGRATION_URL); + +if (RUN && URL_LOCAL) { + process.env['DATABASE_URL'] = INTEGRATION_URL; +} + +import { closePool, query } from '../../lib/db/client'; +import { signAccessToken } from '../../lib/auth/jwt'; +import { handler as listHandler } from './list/handler'; +import { handler as statsHandler } from './stats/handler'; + +const lambdaCtx = {} as Context; + +function makeEvent(overrides: Partial = {}): APIGatewayProxyEventV2 { + return { + routeKey: 'GET /v1/documents', + rawPath: '/v1/documents', + requestContext: { + http: { + method: 'GET', + path: '/v1/documents', + protocol: 'HTTP/2', + sourceIp: '127.0.0.1', + userAgent: 'vitest', + }, + }, + headers: { 'user-agent': 'vitest' }, + isBase64Encoded: false, + ...overrides, + } as APIGatewayProxyEventV2; +} + +async function seed(): Promise<{ orgId: string; token: string }> { + const orgs = await query<{ id: string }>( + `INSERT INTO organizations (name, slug) VALUES ('Acme', 'acme') RETURNING id`, + ); + const orgId = orgs[0]!.id; + const users = await query<{ id: string }>( + `INSERT INTO users (email, password_hash) VALUES ('demo@test.com', 'x') RETURNING id`, + ); + const userId = users[0]!.id; + await query(`INSERT INTO memberships (user_id, org_id, role) VALUES ($1, $2, 'owner')`, [ + userId, + orgId, + ]); + + // Three documents with distinct names / categories / statuses / tags. + const docs: Array<[string, string, string | null, string, string[]]> = [ + // filename, status, category, s3key, tags + ['acme-invoice-q2.pdf', 'ready', 'Invoice', 'raw-uploads/x/1/a.pdf', ['finance', 'billing']], + ['annual-report.pdf', 'ready', 'Report', 'raw-uploads/x/2/b.pdf', ['yearly']], + ['draft-letter.docx', 'analyzing', null, 'raw-uploads/x/3/c.docx', []], + ]; + for (const [filename, status, category, s3key, tags] of docs) { + await query( + `INSERT INTO documents (org_id, uploaded_by, filename, mime_type, size_bytes, s3_key, status, category, tags) + VALUES ($1, $2, $3, 'application/pdf', 1000, $4, $5, $6, $7)`, + [orgId, userId, filename, s3key, status, category, tags], + ); + } + + const access = await signAccessToken({ + userId, + email: 'demo@test.com', + memberships: [{ orgId, role: 'owner' }], + }); + return { orgId, token: access.token }; +} + +describe.skipIf(!RUN || !URL_LOCAL)('documents search / filter / stats', () => { + let auth: { orgId: string; token: string }; + + beforeAll(() => { + if (!process.env['JWT_PRIVATE_KEY'] || !process.env['JWT_PUBLIC_KEY']) { + throw new Error('JWT keys must be set for integration tests.'); + } + }); + + beforeEach(async () => { + await query( + 'TRUNCATE ai_analyses, documents, refresh_tokens, invitations, memberships, organizations, users RESTART IDENTITY CASCADE', + ); + auth = await seed(); + }); + + afterAll(async () => { + await closePool(); + }); + + const headers = (): Record => ({ + authorization: `Bearer ${auth.token}`, + 'x-org-id': auth.orgId, + }); + + async function list( + qs: Record, + ): Promise<{ documents: Array<{ filename: string }> }> { + const res = (await listHandler( + makeEvent({ headers: headers(), queryStringParameters: qs }), + lambdaCtx, + )) as { statusCode: number; body: string }; + expect(res.statusCode).toBe(200); + return JSON.parse(res.body); + } + + it('full-text search matches filename', async () => { + const { documents } = await list({ q: 'invoice' }); + expect(documents).toHaveLength(1); + expect(documents[0]!.filename).toBe('acme-invoice-q2.pdf'); + }); + + it('full-text search matches a tag', async () => { + const { documents } = await list({ q: 'billing' }); + expect(documents.map((d) => d.filename)).toEqual(['acme-invoice-q2.pdf']); + }); + + it('full-text search matches a category', async () => { + const { documents } = await list({ q: 'report' }); + expect(documents.map((d) => d.filename)).toEqual(['annual-report.pdf']); + }); + + it('filters by status', async () => { + const { documents } = await list({ status: 'ready' }); + expect(documents).toHaveLength(2); + }); + + it('filters by category', async () => { + const { documents } = await list({ category: 'Invoice' }); + expect(documents.map((d) => d.filename)).toEqual(['acme-invoice-q2.pdf']); + }); + + it('combines search + filter (both applied)', async () => { + // 'report' matches annual-report.pdf, which is ready → 1 hit. + expect((await list({ q: 'report', status: 'ready' })).documents.map((d) => d.filename)).toEqual( + ['annual-report.pdf'], + ); + // same search but a status it isn't in → 0 hits, proving the filter also applies. + expect((await list({ q: 'report', status: 'analyzing' })).documents).toHaveLength(0); + }); + + it('returns dashboard stats', async () => { + const res = (await statsHandler( + makeEvent({ routeKey: 'GET /v1/documents/stats', headers: headers() }), + lambdaCtx, + )) as { statusCode: number; body: string }; + expect(res.statusCode).toBe(200); + const stats = JSON.parse(res.body); + expect(stats.total).toBe(3); + expect(stats.ready).toBe(2); + expect(stats.processing).toBe(1); + expect(stats.failed).toBe(0); + expect(stats.storageBytes).toBe(3000); + expect(stats.uploadsPerDay).toHaveLength(14); + // all three seeded today → last day's count is 3 + expect(stats.uploadsPerDay[13].count).toBe(3); + }); +}); diff --git a/apps/api/src/handlers/documents/stats/handler.ts b/apps/api/src/handlers/documents/stats/handler.ts new file mode 100644 index 0000000..22f8821 --- /dev/null +++ b/apps/api/src/handlers/documents/stats/handler.ts @@ -0,0 +1,30 @@ +import type { DocumentStats } from '@clouddocs/shared-types'; + +import { DocumentsRepo } from '../../../repositories/documents-repo'; +import { AiAnalysesRepo } from '../../../repositories/ai-analyses-repo'; +import { + compose, + jsonResponse, + withActiveOrg, + withAuth, + withErrorHandler, + withRequestLogger, + withSecrets, + type LambdaHandler, +} from '../../../middlewares'; + +/** GET /v1/documents/stats — aggregate counters for the dashboard. */ +export const handler: LambdaHandler = withSecrets( + withRequestLogger( + compose(withErrorHandler)( + withAuth( + withActiveOrg(async (ctx) => { + const stats = await new DocumentsRepo(ctx.orgId).getStats(); + const analysesThisMonth = await new AiAnalysesRepo(ctx.orgId).countThisMonth(); + const body: DocumentStats = { ...stats, analysesThisMonth }; + return jsonResponse(200, body); + }), + ), + ), + ), +); diff --git a/apps/api/src/repositories/ai-analyses-repo.ts b/apps/api/src/repositories/ai-analyses-repo.ts index 9802d32..adf9db8 100644 --- a/apps/api/src/repositories/ai-analyses-repo.ts +++ b/apps/api/src/repositories/ai-analyses-repo.ts @@ -63,6 +63,15 @@ export class AiAnalysesRepo extends OrgScopedRepository { ); } + /** Count of analyses created since the start of the current month (dashboard KPI). */ + async countThisMonth(): Promise { + const rows = await this.scopedQuery<{ n: string }>( + `SELECT count(*)::text AS n FROM ai_analyses + WHERE org_id = $1 AND created_at >= date_trunc('month', now())`, + ); + return Number(rows[0]?.n ?? 0); + } + /** Distinct count of the given kinds present for a document (for the ready-join). */ async countKinds(documentId: string, kinds: readonly AnalysisKind[]): Promise { const rows = await this.scopedQuery<{ n: string }>( diff --git a/apps/api/src/repositories/documents-repo.ts b/apps/api/src/repositories/documents-repo.ts index 8aa3244..0f35a8a 100644 --- a/apps/api/src/repositories/documents-repo.ts +++ b/apps/api/src/repositories/documents-repo.ts @@ -166,35 +166,92 @@ export class DocumentsRepo extends OrgScopedRepository { } /** - * Keyset pagination, newest first. Pass the previous page's last `id` as - * `cursor` to get the next page. Fetches one extra row to know whether more - * exist without a second count query. + * Keyset pagination, newest first, with optional full-text search (`q` over + * filename+category+tags) and status/category filters. Fetches one extra row + * to know whether more exist without a second count query. The WHERE clause is + * built dynamically; `$1` is always the org id (prepended by scopedQuery). */ - async list( - limit: number, - cursor?: string, - ): Promise<{ rows: DocumentRow[]; nextCursor: string | null }> { - const rows = cursor - ? await this.scopedQuery( - `SELECT * FROM documents - WHERE org_id = $1 - AND (created_at, id) < (SELECT created_at, id FROM documents WHERE id = $2 AND org_id = $1) - ORDER BY created_at DESC, id DESC - LIMIT $3`, - [cursor, limit + 1], - ) - : await this.scopedQuery( - `SELECT * FROM documents - WHERE org_id = $1 - ORDER BY created_at DESC, id DESC - LIMIT $2`, - [limit + 1], - ); - - const hasMore = rows.length > limit; - const page = hasMore ? rows.slice(0, limit) : rows; + async list(opts: { + limit: number; + cursor?: string; + q?: string; + status?: DocumentStatus; + category?: string; + }): Promise<{ rows: DocumentRow[]; nextCursor: string | null }> { + const where = ['org_id = $1']; + const params: unknown[] = []; + let p = 1; + const add = (value: unknown): string => { + params.push(value); + return `$${++p}`; + }; + + if (opts.q) where.push(`search_tsv @@ websearch_to_tsquery('simple', ${add(opts.q)})`); + if (opts.status) where.push(`status = ${add(opts.status)}`); + if (opts.category) where.push(`category = ${add(opts.category)}`); + if (opts.cursor) { + where.push( + `(created_at, id) < (SELECT created_at, id FROM documents WHERE id = ${add(opts.cursor)} AND org_id = $1)`, + ); + } + const limitPlaceholder = add(opts.limit + 1); + + const rows = await this.scopedQuery( + `SELECT * FROM documents + WHERE ${where.join(' AND ')} + ORDER BY created_at DESC, id DESC + LIMIT ${limitPlaceholder}`, + params, + ); + + const hasMore = rows.length > opts.limit; + const page = hasMore ? rows.slice(0, opts.limit) : rows; return { rows: page, nextCursor: hasMore ? (page[page.length - 1]?.id ?? null) : null }; } + + /** Aggregate counters + a 14-day uploads series for the dashboard. */ + async getStats(): Promise<{ + total: number; + ready: number; + processing: number; + failed: number; + storageBytes: number; + uploadsPerDay: Array<{ date: string; count: number }>; + }> { + const agg = await this.scopedQuery<{ + total: string; + ready: string; + processing: string; + failed: string; + bytes: string; + }>( + `SELECT + count(*) AS total, + count(*) FILTER (WHERE status = 'ready') AS ready, + count(*) FILTER (WHERE status = 'failed') AS failed, + count(*) FILTER (WHERE status NOT IN ('ready', 'failed')) AS processing, + coalesce(sum(size_bytes), 0) AS bytes + FROM documents WHERE org_id = $1`, + ); + const series = await this.scopedQuery<{ date: string; count: string }>( + `SELECT to_char(d::date, 'YYYY-MM-DD') AS date, coalesce(c.count, 0) AS count + FROM generate_series(current_date - interval '13 days', current_date, interval '1 day') AS d + LEFT JOIN ( + SELECT created_at::date AS day, count(*) AS count + FROM documents WHERE org_id = $1 GROUP BY 1 + ) c ON c.day = d::date + ORDER BY d`, + ); + const a = agg[0]; + return { + total: Number(a?.total ?? 0), + ready: Number(a?.ready ?? 0), + processing: Number(a?.processing ?? 0), + failed: Number(a?.failed ?? 0), + storageBytes: Number(a?.bytes ?? 0), + uploadsPerDay: series.map((r) => ({ date: r.date, count: Number(r.count) })), + }; + } } /** Map a DB row to the API/shared-types `Document` shape. */ diff --git a/apps/web-e2e/src/auth.spec.ts b/apps/web-e2e/src/auth.spec.ts index 2689831..eaff057 100644 --- a/apps/web-e2e/src/auth.spec.ts +++ b/apps/web-e2e/src/auth.spec.ts @@ -68,6 +68,30 @@ test('register → dashboard → logout', async ({ page }) => { }), ); await page.route('**/v1/auth/logout', (route) => route.fulfill({ status: 204, body: '' })); + // The dashboard loads stats + recent docs on arrival — stub them so the test + // doesn't depend on the network. + await page.route(/\/v1\/documents\/stats/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + total: 0, + ready: 0, + processing: 0, + failed: 0, + storageBytes: 0, + analysesThisMonth: 0, + uploadsPerDay: [], + }), + }), + ); + await page.route(/\/v1\/documents\?/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ documents: [], nextCursor: null }), + }), + ); await page.goto('/auth/register'); @@ -80,10 +104,10 @@ test('register → dashboard → logout', async ({ page }) => { await page.getByTestId('submit').click(); - // Landed on the authenticated dashboard with session data rendered. + // Landed on the authenticated dashboard. await expect(page).toHaveURL(/\/dashboard/); await expect(page.getByTestId('dashboard-heading')).toContainText('Ada Lovelace'); - await expect(page.getByTestId('session-email')).toHaveText('ada@example.com'); + await expect(page.getByTestId('kpis')).toBeVisible(); // Sign out returns to login. await page.getByRole('button', { name: 'Sign out' }).click(); diff --git a/apps/web-e2e/src/search-dashboard.spec.ts b/apps/web-e2e/src/search-dashboard.spec.ts new file mode 100644 index 0000000..96bc5b6 --- /dev/null +++ b/apps/web-e2e/src/search-dashboard.spec.ts @@ -0,0 +1,137 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Phase 5 e2e (mocked API): search filters the documents list, and the + * dashboard renders KPIs + the uploads sparkline. + */ + +const ORG_ID = '33333333-3333-4333-8333-333333333333'; + +const SESSION = { + user: { + id: '11111111-1111-4111-8111-111111111111', + email: 'ada@example.com', + displayName: 'Ada Lovelace', + avatarUrl: null, + emailVerified: false, + createdAt: '2026-05-25T00:00:00.000Z', + }, + memberships: [ + { + id: '22222222-2222-4222-8222-222222222222', + orgId: ORG_ID, + role: 'owner', + organization: { + id: ORG_ID, + name: 'Analytical Engines', + slug: 'analytical-engines', + plan: 'free', + createdAt: '2026-05-25T00:00:00.000Z', + }, + }, + ], + tokens: { accessToken: 'mock.access.token', accessTokenExpiresAt: '2026-05-25T00:15:00.000Z' }, +}; + +const json = (body: unknown, status = 200) => ({ + status, + contentType: 'application/json', + body: JSON.stringify(body), +}); + +function doc(id: string, filename: string, status = 'ready', category: string | null = null) { + return { + id, + orgId: ORG_ID, + uploadedBy: SESSION.user.id, + filename, + mimeType: 'application/pdf', + sizeBytes: 1024, + status, + error: null, + category, + tags: [], + language: 'en', + pageCount: 1, + createdAt: '2026-05-26T10:00:00.000Z', + updatedAt: '2026-05-26T10:00:00.000Z', + }; +} + +const INVOICE = doc('44444444-4444-4444-8444-444444444444', 'acme-invoice.pdf', 'ready', 'Invoice'); +const REPORT = doc('55555555-5555-4555-8555-555555555555', 'annual-report.pdf', 'ready', 'Report'); + +async function stubAuthenticatedBoot(page: Page): Promise { + await page.route('**/v1/auth/refresh', (route) => route.fulfill(json(SESSION))); +} + +test('search filters the document list by query', async ({ page }) => { + await stubAuthenticatedBoot(page); + + await page.route(/\/v1\/documents\/stats/, (route) => + route.fulfill( + json({ + total: 2, + ready: 2, + processing: 0, + failed: 0, + storageBytes: 2048, + analysesThisMonth: 4, + uploadsPerDay: [], + }), + ), + ); + // List endpoint (always has a ?limit= query) honours the `q` param in the mock. + await page.route(/\/v1\/documents\?/, (route) => { + const url = new URL(route.request().url()); + const q = (url.searchParams.get('q') ?? '').toLowerCase(); + const all = [INVOICE, REPORT]; + const documents = q + ? all.filter((d) => d.filename.includes(q) || (d.category ?? '').toLowerCase().includes(q)) + : all; + return route.fulfill(json({ documents, nextCursor: null })); + }); + + await page.goto('/documents'); + + // Both documents listed initially. + await expect(page.getByTestId('documents-table')).toContainText('acme-invoice.pdf'); + await expect(page.getByTestId('documents-table')).toContainText('annual-report.pdf'); + + // Search narrows to the invoice (debounced). + await page.getByTestId('search').fill('invoice'); + await expect(page.getByTestId('documents-table')).toContainText('acme-invoice.pdf'); + await expect(page.getByTestId('documents-table')).not.toContainText('annual-report.pdf'); +}); + +test('dashboard renders KPIs, sparkline and recent documents', async ({ page }) => { + await stubAuthenticatedBoot(page); + + await page.route(/\/v1\/documents\/stats/, (route) => + route.fulfill( + json({ + total: 7, + ready: 5, + processing: 1, + failed: 1, + storageBytes: 5 * 1024 * 1024, + analysesThisMonth: 10, + uploadsPerDay: Array.from({ length: 14 }, (_, i) => ({ + date: `2026-05-${String(13 + i).padStart(2, '0')}`, + count: i % 3, + })), + }), + ), + ); + await page.route(/\/v1\/documents\?/, (route) => + route.fulfill(json({ documents: [INVOICE, REPORT], nextCursor: null })), + ); + + await page.goto('/dashboard'); + + await expect(page.getByTestId('kpis')).toContainText('Documents'); + await expect(page.getByTestId('kpis')).toContainText('7'); // total + await expect(page.getByTestId('kpis')).toContainText('5.0 MB'); // storage + await expect(page.getByTestId('sparkline')).toBeVisible(); + await expect(page.getByTestId('recent')).toContainText('acme-invoice.pdf'); +}); diff --git a/apps/web/src/app/features/dashboard/dashboard.page.ts b/apps/web/src/app/features/dashboard/dashboard.page.ts index b81ee61..0d28df1 100644 --- a/apps/web/src/app/features/dashboard/dashboard.page.ts +++ b/apps/web/src/app/features/dashboard/dashboard.page.ts @@ -1,29 +1,44 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import type { Document, DocumentStats } from '@clouddocs/shared-types'; import { AuthService } from '../../core/auth/auth.service'; +import { apiErrorMessage } from '../../shared/utils/api-error'; +import { DocumentsService } from '../documents/documents.service'; +import { statusBadgeClass } from '../documents/document-status'; -/** - * Placeholder landing page after login. Confirms the authenticated session is - * wired end-to-end (user + memberships from `/v1/auth/me` / the login response). - * The real KPI dashboard arrives once documents exist (Phase 3+). - */ @Component({ selector: 'app-dashboard', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], template: `

Welcome{{ user()?.displayName ? ', ' + user()?.displayName : '' }}

- You're signed in to - {{ activeOrg()?.name ?? 'your workspace' }}. Document upload lands in the next phase. + Your workspace at a glance — + {{ activeOrg()?.name ?? 'your organization' }}

-
- @for (kpi of kpis; track kpi.label) { + @if (loadError()) { +

+ {{ loadError() }} +

+ } + + +
+ @for (kpi of kpis(); track kpi.label) {

{{ kpi.label }}

{{ kpi.value }}

@@ -31,32 +46,122 @@ import { AuthService } from '../../core/auth/auth.service'; }
-
-

Your session

-
-
-
Email
-
{{ user()?.email }}
-
-
-
Organization
-
{{ activeOrg()?.name }} · {{ memberships()[0]?.role }}
+
+ +
+

Uploads · last 14 days

+ @if (stats(); as s) { + + + +

+ {{ totalUploads() }} upload{{ totalUploads() === 1 ? '' : 's' }} in the period +

+ } @else { +

Loading…

+ } +
+ + +
+
+

Recent documents

+ View all
-
-
+ @if (recent().length === 0) { +

No documents yet.

+ } @else { +
    + @for (doc of recent(); track doc.id) { +
  • + {{ doc.filename }} + {{ doc.status }} +
  • + } +
+ } + +
`, }) -export class DashboardPage { +export class DashboardPage implements OnInit { private readonly auth = inject(AuthService); + private readonly documentsApi = inject(DocumentsService); protected readonly user = this.auth.user; - protected readonly memberships = this.auth.memberships; protected readonly activeOrg = this.auth.activeOrg; - protected readonly kpis = [ - { label: 'Documents', value: '0' }, - { label: 'Processed', value: '0' }, - { label: 'Storage', value: '0 MB' }, - { label: 'Analyses', value: '0' }, - ]; + protected readonly stats = signal(null); + protected readonly recent = signal([]); + protected readonly loadError = signal(null); + + protected readonly statusClass = statusBadgeClass; + + protected readonly kpis = computed(() => { + const s = this.stats(); + return [ + { label: 'Documents', value: s ? String(s.total) : '—' }, + { label: 'Ready', value: s ? String(s.ready) : '—' }, + { label: 'Processing', value: s ? String(s.processing) : '—' }, + { label: 'Storage', value: s ? formatBytes(s.storageBytes) : '—' }, + ]; + }); + + protected readonly totalUploads = computed(() => + (this.stats()?.uploadsPerDay ?? []).reduce((sum, d) => sum + d.count, 0), + ); + + /** Build an SVG polyline (0..280 x, 0..60 y) from the 14-day series. */ + protected readonly sparklinePoints = computed(() => { + const series = this.stats()?.uploadsPerDay ?? []; + if (series.length === 0) return ''; + const max = Math.max(1, ...series.map((d) => d.count)); + const stepX = 280 / Math.max(1, series.length - 1); + return series + .map((d, i) => { + const x = i * stepX; + const y = 58 - (d.count / max) * 54; // leave a little headroom + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + }); + + ngOnInit(): void { + this.documentsApi.stats().subscribe({ + next: (s) => this.stats.set(s), + error: (err) => this.loadError.set(apiErrorMessage(err, 'Could not load stats.')), + }); + this.documentsApi.list({ limit: 5 }).subscribe({ + next: (res) => this.recent.set(res.documents), + error: () => undefined, // KPI error already surfaced + }); + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } diff --git a/apps/web/src/app/features/documents/documents.page.ts b/apps/web/src/app/features/documents/documents.page.ts index c70ce5e..416f528 100644 --- a/apps/web/src/app/features/documents/documents.page.ts +++ b/apps/web/src/app/features/documents/documents.page.ts @@ -1,9 +1,9 @@ import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { RouterLink } from '@angular/router'; -import { timer } from 'rxjs'; +import { debounceTime, distinctUntilChanged, timer } from 'rxjs'; -import type { Document } from '@clouddocs/shared-types'; +import { DOCUMENT_CATEGORIES, type Document } from '@clouddocs/shared-types'; import { apiErrorMessage } from '../../shared/utils/api-error'; import { DocumentsService } from './documents.service'; @@ -66,8 +66,42 @@ interface RejectedFile { } + +
+ + + +
+ -
+
@if (loading()) {

Loading…

} @else if (loadError()) { @@ -79,8 +113,13 @@ interface RejectedFile { class="rounded-xl border border-dashed border-border bg-surface-1 px-6 py-12 text-center" data-testid="empty-state" > -

No documents yet

-

Upload your first file to get started.

+ @if (isFiltered()) { +

No documents match your search

+

Try a different term or clear the filters.

+ } @else { +

No documents yet

+

Upload your first file to get started.

+ }
} @else { @@ -132,7 +171,17 @@ export class DocumentsPage implements OnInit { protected readonly uploads = signal([]); protected readonly rejected = signal([]); + protected readonly search = signal(''); + protected readonly statusFilter = signal(''); + protected readonly categoryFilter = signal(''); + protected readonly categories = DOCUMENT_CATEGORIES; + constructor() { + // Debounce the search box so we don't fire a request per keystroke. + toObservable(this.search) + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed()) + .subscribe(() => this.refresh()); + // While any document is still being processed, re-fetch the list every 3s // so statuses (and categories) update without a manual refresh. timer(3000, 3000) @@ -146,6 +195,10 @@ export class DocumentsPage implements OnInit { this.refresh(); } + protected isFiltered(): boolean { + return !!(this.search().trim() || this.statusFilter() || this.categoryFilter()); + } + protected onFilesSelected(files: File[]): void { this.rejected.set([]); const accepted: File[] = []; @@ -167,19 +220,25 @@ export class DocumentsPage implements OnInit { } } - private refresh(): void { + protected refresh(): void { this.loading.set(true); this.loadError.set(null); - this.documentsApi.list().subscribe({ - next: (res) => { - this.documents.set(res.documents); - this.loading.set(false); - }, - error: (err) => { - this.loadError.set(apiErrorMessage(err, 'Could not load documents.')); - this.loading.set(false); - }, - }); + this.documentsApi + .list({ + ...(this.search().trim() ? { q: this.search().trim() } : {}), + ...(this.statusFilter() ? { status: this.statusFilter() } : {}), + ...(this.categoryFilter() ? { category: this.categoryFilter() } : {}), + }) + .subscribe({ + next: (res) => { + this.documents.set(res.documents); + this.loading.set(false); + }, + error: (err) => { + this.loadError.set(apiErrorMessage(err, 'Could not load documents.')); + this.loading.set(false); + }, + }); } protected uploadLabel(u: UploadHandle): string { diff --git a/apps/web/src/app/features/documents/documents.service.ts b/apps/web/src/app/features/documents/documents.service.ts index 5039df2..172a32c 100644 --- a/apps/web/src/app/features/documents/documents.service.ts +++ b/apps/web/src/app/features/documents/documents.service.ts @@ -8,9 +8,18 @@ import type { Document, DocumentDetailResponse, DocumentListResponse, + DocumentStats, DownloadResponse, } from '@clouddocs/shared-types'; +export interface ListParams { + cursor?: string; + limit?: number; + q?: string; + status?: string; + category?: string; +} + import { API_BASE_URL } from '../../core/api/api.config'; import { AuthService } from '../../core/auth/auth.service'; @@ -49,12 +58,20 @@ export class DocumentsService { ); } - list(cursor?: string, limit = 20): Observable { - const params: Record = { limit: String(limit) }; - if (cursor) params['cursor'] = cursor; + list(opts: ListParams = {}): Observable { + const params: Record = { limit: String(opts.limit ?? 20) }; + if (opts.cursor) params['cursor'] = opts.cursor; + if (opts.q) params['q'] = opts.q; + if (opts.status) params['status'] = opts.status; + if (opts.category) params['category'] = opts.category; return this.http.get(this.url, { headers: this.orgHeaders(), params }); } + /** Aggregate counters for the dashboard. */ + stats(): Observable { + return this.http.get(`${this.url}/stats`, { headers: this.orgHeaders() }); + } + /** Document detail + its AI analyses. */ get(id: string): Observable { return this.http.get(`${this.url}/${id}`, { diff --git a/infra/lib/stacks/api-stack.ts b/infra/lib/stacks/api-stack.ts index b9723c2..fee4812 100644 --- a/infra/lib/stacks/api-stack.ts +++ b/infra/lib/stacks/api-stack.ts @@ -89,6 +89,14 @@ const DOC_ROUTES: readonly DocRoute[] = [ path: '/v1/documents', description: 'Keyset-paginated list of the active org documents.', }, + { + id: 'Stats', + handlerDir: 'stats', + method: apigwv2.HttpMethod.GET, + // Literal segment — HTTP API prioritises this over the `{id}` route below. + path: '/v1/documents/stats', + description: 'Aggregate document counters for the dashboard.', + }, { id: 'Get', handlerDir: 'get', diff --git a/libs/shared-types/src/schemas/documents.spec.ts b/libs/shared-types/src/schemas/documents.spec.ts index 2a90ae5..9f6a36e 100644 --- a/libs/shared-types/src/schemas/documents.spec.ts +++ b/libs/shared-types/src/schemas/documents.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { CreateDocumentDtoSchema, DocumentListQuerySchema, + DocumentStatsSchema, MAX_UPLOAD_SIZE_BYTES, } from './documents'; @@ -41,4 +42,34 @@ describe('DocumentListQuerySchema', () => { it('caps limit at 100', () => { expect(() => DocumentListQuerySchema.parse({ limit: '101' })).toThrow(); }); + + it('accepts search + filter params and trims q', () => { + const parsed = DocumentListQuerySchema.parse({ + q: ' invoice ', + status: 'ready', + category: 'Invoice', + }); + expect(parsed.q).toBe('invoice'); + expect(parsed.status).toBe('ready'); + expect(parsed.category).toBe('Invoice'); + }); + + it('rejects an unknown status', () => { + expect(() => DocumentListQuerySchema.parse({ status: 'bogus' })).toThrow(); + }); +}); + +describe('DocumentStatsSchema', () => { + it('accepts a well-formed stats payload', () => { + const v = { + total: 3, + ready: 2, + processing: 1, + failed: 0, + storageBytes: 4096, + analysesThisMonth: 4, + uploadsPerDay: [{ date: '2026-05-26', count: 2 }], + }; + expect(DocumentStatsSchema.parse(v)).toEqual(v); + }); }); diff --git a/libs/shared-types/src/schemas/documents.ts b/libs/shared-types/src/schemas/documents.ts index cf3425d..a52dd91 100644 --- a/libs/shared-types/src/schemas/documents.ts +++ b/libs/shared-types/src/schemas/documents.ts @@ -80,6 +80,10 @@ export type CreateDocumentResponse = z.infer; @@ -95,3 +99,23 @@ export const DownloadResponseSchema = z.object({ expiresInSeconds: z.number().int().positive(), }); export type DownloadResponse = z.infer; + +/** One day's upload count for the dashboard sparkline. */ +export const UploadsPerDaySchema = z.object({ + date: z.string(), // YYYY-MM-DD + count: z.number().int().nonnegative(), +}); +export type UploadsPerDay = z.infer; + +/** Aggregate counters for the dashboard (org-scoped). */ +export const DocumentStatsSchema = z.object({ + total: z.number().int().nonnegative(), + ready: z.number().int().nonnegative(), + processing: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + storageBytes: z.number().int().nonnegative(), + analysesThisMonth: z.number().int().nonnegative(), + /** Oldest → newest, one entry per day (last 14 days). */ + uploadsPerDay: z.array(UploadsPerDaySchema), +}); +export type DocumentStats = z.infer; diff --git a/tools/migrations/1779805600362_documents-search.sql b/tools/migrations/1779805600362_documents-search.sql new file mode 100644 index 0000000..ae7e4e3 --- /dev/null +++ b/tools/migrations/1779805600362_documents-search.sql @@ -0,0 +1,39 @@ +-- Up Migration + +-- Full-text search vector over filename + category + tags (plan §9). +-- A STORED generated column can't be used here: to_tsvector with a named config +-- ('simple') isn't treated as immutable by Postgres. We maintain the column via +-- a trigger instead (fires when the searchable fields change, e.g. the classify +-- worker setting category/tags). 'simple' config (no stemming) suits short +-- metadata like filenames. +ALTER TABLE documents ADD COLUMN search_tsv tsvector; + +CREATE FUNCTION documents_search_tsv() RETURNS trigger AS $$ +BEGIN + NEW.search_tsv := to_tsvector( + 'simple', + NEW.filename || ' ' || coalesce(NEW.category, '') || ' ' || array_to_string(NEW.tags, ' ') + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER documents_search_tsv_trg + BEFORE INSERT OR UPDATE OF filename, category, tags ON documents + FOR EACH ROW EXECUTE FUNCTION documents_search_tsv(); + +-- Backfill any existing rows. +UPDATE documents SET search_tsv = to_tsvector( + 'simple', + filename || ' ' || coalesce(category, '') || ' ' || array_to_string(tags, ' ') +); + +CREATE INDEX idx_documents_search ON documents USING GIN (search_tsv); + + +-- Down Migration + +DROP INDEX IF EXISTS idx_documents_search; +DROP TRIGGER IF EXISTS documents_search_tsv_trg ON documents; +DROP FUNCTION IF EXISTS documents_search_tsv(); +ALTER TABLE documents DROP COLUMN IF EXISTS search_tsv;