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
2 changes: 1 addition & 1 deletion apps/api/src/handlers/documents/list/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
Expand Down
170 changes: 170 additions & 0 deletions apps/api/src/handlers/documents/search.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string, string> => ({
authorization: `Bearer ${auth.token}`,
'x-org-id': auth.orgId,
});

async function list(
qs: Record<string, string>,
): 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);
});
});
30 changes: 30 additions & 0 deletions apps/api/src/handlers/documents/stats/handler.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
),
),
),
);
9 changes: 9 additions & 0 deletions apps/api/src/repositories/ai-analyses-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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<number> {
const rows = await this.scopedQuery<{ n: string }>(
Expand Down
109 changes: 83 additions & 26 deletions apps/api/src/repositories/documents-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentRow>(
`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<DocumentRow>(
`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<DocumentRow>(
`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. */
Expand Down
28 changes: 26 additions & 2 deletions apps/web-e2e/src/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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();
Expand Down
Loading
Loading