Skip to content
Closed
18 changes: 18 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ entry. See `CONTRIBUTING.md` § Releases & changelog.

## [Unreleased]

### Added — admin UI for Knowledge-Graph datasets: upload / schema / delete (#532)

- New page `web-ui/app/admin/datasets/page.tsx` — the web-ui face of the #430
CSV-import path, closing the admin-UI gap that #430's triage acceptance
criteria called for and that was deliberately deferred to Phase 14
(`docs/middleware-agent-handoff.md` §13). Upload a CSV, browse the inferred
schema and a paginated row preview, and delete a dataset behind a two-step
confirm. The upload surfaces the mandatory privacy-scan (`masked / scanned`
cells) and truncation stats returned by the ingest pipeline, so the operator
sees what was masked before it landed in the graph.
- API client added to `web-ui/app/_lib/api.ts`
(`listDatasets` / `getDataset` / `getDatasetRows` / `uploadDataset` /
`deleteDataset`) over the existing `/api/v1/datasets*` REST surface
(cookie-session auth, owner-scoped). No new backend — the routes shipped in
#430 and are tested server-side.
- i18n namespace `adminDatasets` mirrored across `messages/{en,de}.json`; a
card in the `/admin` index under the Knowledge group. Component test in
`web-ui/app/admin/datasets/__tests__/page.test.tsx`.
### Added — product documentation for the AI marking (#649, epic #642)

- **New `docs/ai-act-transparency.md`** — what omadia actually marks and, just as
Expand Down
12 changes: 0 additions & 12 deletions docs/middleware-agent-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -1814,18 +1814,6 @@ gekettet, weil `requires` beim Boot enforced wird): docs-RFC (diese PR)
omadia-ui-Orchestrator-Consumer. Details + per-PR-Doc-Pflichten in §15
des RFC.

### Phase 14 — Admin-UI für Dataset-Upload/Schema/Delete (#430 Follow-up)

Der #430-Scope (CSV-Import + `query_dataset`-Tool, siehe §3 und §7) deckt
absichtlich **keine** Admin-UI ab — Upload/Schema-Browse/Delete bleibt
API-only (`POST/GET/DELETE /api/v1/datasets*`, siehe §3). #430's eigene
Triage-Acceptance-Criteria verlangen aber genau diese UI; der Branch
schließt das Issue deshalb NICHT, sondern "addresses" es — ein
Folge-Issue für die Admin-UI-Seite (`web-ui/app/admin/datasets/` o.ä.,
Upload-Dropzone + Schema-Tabelle + Zeilen-Preview + Delete-Bestätigung,
Pattern analog zur bestehenden Package-Upload-Seite) ist offen zu
erfassen.

---

## 14. Commands (vom `middleware/`-Dir aus)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2930,15 +2930,25 @@ export class InMemoryKnowledgeGraph implements KnowledgeGraph {
async listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
const offset = Math.max(0, opts.offset ?? 0);
return [...this.datasets.values()]
.filter((d) => d.ownerOmadiaUserId === opts.ownerOmadiaUserId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, limit)
.slice(offset, offset + limit)
.map((d) => this.datasetToSummary(d));
}

async countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number> {
let count = 0;
for (const d of this.datasets.values()) {
if (d.ownerOmadiaUserId === opts.ownerOmadiaUserId) count += 1;
}
return count;
}

async getDataset(
datasetId: string,
viewerOmadiaUserId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,19 +702,31 @@ export class NeonKnowledgeGraph implements KnowledgeGraph {
async listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
const offset = Math.max(0, opts.offset ?? 0);
const result = await this.pool.query<DatasetRow>(
`SELECT id, name, source_file_name, owner_omadia_user_id, row_count, columns, created_at
FROM datasets
WHERE tenant_id = $1 AND owner_omadia_user_id = $2
ORDER BY created_at DESC
LIMIT $3`,
[this.tenantId, opts.ownerOmadiaUserId, limit],
LIMIT $3 OFFSET $4`,
[this.tenantId, opts.ownerOmadiaUserId, limit, offset],
);
return result.rows.map((r) => this.datasetRowToSummary(r));
}

async countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number> {
const result = await this.pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM datasets
WHERE tenant_id = $1 AND owner_omadia_user_id = $2`,
[this.tenantId, opts.ownerOmadiaUserId],
);
return Number(result.rows[0]?.count ?? 0);
}

async getDataset(
datasetId: string,
viewerOmadiaUserId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,13 @@ export class CaptureFilteringKnowledgeGraph implements KnowledgeGraph {
listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
return this.inner.listDatasets(opts);
}
countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number> {
return this.inner.countDatasets(opts);
}
getDataset(
datasetId: string,
viewerOmadiaUserId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -542,9 +542,13 @@ export class InconsistencyTriggeringKnowledgeGraph implements KnowledgeGraph {
listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
return this.inner.listDatasets(opts);
}
countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number> {
return this.inner.countDatasets(opts);
}
getDataset(
datasetId: string,
viewerOmadiaUserId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,13 @@ export class MergeTriggeringKnowledgeGraph implements KnowledgeGraph {
listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
return this.inner.listDatasets(opts);
}
countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number> {
return this.inner.countDatasets(opts);
}
getDataset(
datasetId: string,
viewerOmadiaUserId: string,
Expand Down
10 changes: 9 additions & 1 deletion middleware/packages/plugin-api/src/knowledgeGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,11 +711,19 @@ export interface KnowledgeGraph {
* KnowledgeGraph boundary, never inside it.
*/
ingestDataset(input: DatasetIngest): Promise<DatasetIngestResult>;
/** #430 — list datasets owned by the caller, most-recent first. */
/**
* #430 — list datasets owned by the caller, most-recent first.
* `limit` clamped to [1, 200] server-side (default 50); `offset` skips
* that many rows for pagination. Pair with {@link countDatasets} to render
* a "showing N of M" hint instead of silently truncating at the cap.
*/
listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]>;
/** #430 — total datasets owned by the caller, ignoring limit/offset. */
countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number>;
/**
* #430 — read one dataset's metadata + inferred schema. Null when
* missing or the viewer doesn't own it (ACL mirrors `/api/v1/memory`:
Expand Down
20 changes: 18 additions & 2 deletions middleware/src/routes/datasets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ const RowsQuerySchema = z.object({
offset: z.coerce.number().int().min(0).optional(),
});

// Same shape as RowsQuerySchema — the dataset list is paginated too, so the
// admin UI can page past the 50-row default cap instead of silently hiding
// older datasets (they'd otherwise be un-viewable and un-deletable here).
const ListQuerySchema = RowsQuerySchema;
const DEFAULT_LIST_LIMIT = 50;

function requireSessionUserId(req: Request, res: Response): string | null {
const id = req.session?.omadia_user_id;
if (!id) {
Expand Down Expand Up @@ -134,9 +140,19 @@ export function createDatasetsRouter(deps: { graph: KnowledgeGraph }): Router {
router.get('/', async (req: Request, res: Response) => {
const sessionUserId = requireSessionUserId(req, res);
if (!sessionUserId) return;
const parsed = ListQuerySchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ code: 'dataset.invalid_query', issues: parsed.error.issues });
return;
}
const limit = parsed.data.limit ?? DEFAULT_LIST_LIMIT;
const offset = parsed.data.offset ?? 0;
try {
const items = await deps.graph.listDatasets({ ownerOmadiaUserId: sessionUserId });
res.json({ items });
const [items, total] = await Promise.all([
deps.graph.listDatasets({ ownerOmadiaUserId: sessionUserId, limit, offset }),
deps.graph.countDatasets({ ownerOmadiaUserId: sessionUserId }),
]);
res.json({ items, total, limit, offset });
} catch (err) {
const { status, code, message } = mapErrorToHttp(err);
res.status(status).json({ code, message });
Expand Down
45 changes: 44 additions & 1 deletion middleware/test/datasetsRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,17 @@ describe('POST /api/v1/datasets', () => {
const { datasetId } = uploadBody.dataset;

const listRes = await fetch(h.baseUrl);
const listBody = (await listRes.json()) as { items: Array<{ id: string; name: string }> };
const listBody = (await listRes.json()) as {
items: Array<{ id: string; name: string }>;
total: number;
limit: number;
offset: number;
};
assert.equal(listBody.items.length, 1);
assert.equal(listBody.items[0]?.name, 'People');
assert.equal(listBody.total, 1);
assert.equal(listBody.limit, 50);
assert.equal(listBody.offset, 0);

const schemaRes = await fetch(`${h.baseUrl}/${datasetId}`);
const schemaBody = (await schemaRes.json()) as { columns: Array<{ name: string }> };
Expand All @@ -124,6 +132,41 @@ describe('POST /api/v1/datasets', () => {
assert.equal(afterDeleteRes.status, 404);
});

it('paginates the list with limit/offset and reports the pre-limit total', async () => {
const paged = await makeHarness('user-1');
for (const nm of ['A', 'B', 'C']) {
const form = new FormData();
form.append('file', new Blob([CSV], { type: 'text/csv' }), 'people.csv');
form.append('name', nm);
const res = await fetch(paged.baseUrl, { method: 'POST', body: form });
assert.equal(res.status, 201);
}

const firstPage = (await (await fetch(`${paged.baseUrl}?limit=2&offset=0`)).json()) as {
items: Array<{ name: string }>;
total: number;
limit: number;
offset: number;
};
assert.equal(firstPage.total, 3);
assert.equal(firstPage.limit, 2);
assert.equal(firstPage.offset, 0);
assert.equal(firstPage.items.length, 2);

const secondPage = (await (await fetch(`${paged.baseUrl}?limit=2&offset=2`)).json()) as {
items: Array<{ name: string }>;
total: number;
};
assert.equal(secondPage.items.length, 1);
assert.equal(secondPage.total, 3);

// A malformed query is a 400, not a silent full-list dump.
const bad = await fetch(`${paged.baseUrl}?limit=-1`);
assert.equal(bad.status, 400);

await paged.close();
});

it('returns a JSON {code, message} body — not an unhandled rejection / Express default page — when importCsvDataset throws', async () => {
const throwing = await makeHarness('user-1', new ThrowingIngestKnowledgeGraph());
const form = new FormData();
Expand Down
112 changes: 112 additions & 0 deletions web-ui/app/_lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
const { pathname } = window.location;
if (pathname === '/login' || pathname === '/setup') return;
const returnPath = pathname + window.location.search;
window.location.assign(`/login?return=${encodeURIComponent(returnPath)}`);

Check warning on line 115 in web-ui/app/_lib/api.ts

View workflow job for this annotation

GitHub Actions / web-ui (lint + typecheck + vitest)

Do not use `window.location.assign()` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
}

async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
Expand Down Expand Up @@ -4656,6 +4656,118 @@
return getJson(`${WEBHOOKS_BASE}/subscriptions/${encodeURIComponent(id)}/deliveries`);
}

// -----------------------------------------------------------------------------
// Knowledge-Graph datasets (issue #532 — admin UI for the #430 CSV-import path).
// The full REST surface lives in middleware/src/routes/datasets.ts under
// /api/v1/datasets (cookie-session auth, owner-scoped — cross-owner reads 404).
// -----------------------------------------------------------------------------

const DATASETS_BASE = '/v1/datasets';

export type DatasetColumnType = 'string' | 'number' | 'boolean' | 'date';

export interface DatasetColumnSchema {
name: string;
type: DatasetColumnType;
/** First non-empty value, surfaced as a schema-preview hint. */
sample?: string;
}

export interface DatasetSummary {
id: string;
name: string;
sourceFileName: string;
ownerOmadiaUserId: string;
rowCount: number;
columns: DatasetColumnSchema[];
createdAt: string;
}

/** 201 body of POST /api/v1/datasets — the multipart CSV upload. */
export interface DatasetUploadResult {
dataset: { datasetId: string; rowCount: number; graphNodeId: string };
privacyScan: { scannedCells: number; maskedCells: number };
truncation: { truncatedCellCount: number; truncatedColumns: string[] };
}

/**
* GET /api/v1/datasets/:id/rows. `rows` is populated for a row query (no
* aggregate) — but `KnowledgeGraph.DatasetQueryResult.rows` is optional, so
* mirror that and let callers guard rather than assume it's always present.
*/
export interface DatasetRowsResult {
rows?: Array<Record<string, unknown>>;
/** Pre-limit match count, for a "showing X of Y" hint. */
totalMatched: number;
}

/** GET /api/v1/datasets — the owner-scoped, paginated dataset list. */
export interface DatasetListResult {
items: DatasetSummary[];
/** Total datasets the caller owns, before limit/offset. */
total: number;
limit: number;
offset: number;
}

export async function listDatasets(
opts: { limit?: number; offset?: number } = {},
): Promise<DatasetListResult> {
const params = new URLSearchParams();
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
const qs = params.toString();
return getJson<DatasetListResult>(`${DATASETS_BASE}${qs ? `?${qs}` : ''}`);
}

export async function getDataset(id: string): Promise<DatasetSummary> {
return getJson<DatasetSummary>(`${DATASETS_BASE}/${encodeURIComponent(id)}`);
}

export async function getDatasetRows(
id: string,
opts: { limit?: number; offset?: number } = {},
): Promise<DatasetRowsResult> {
const params = new URLSearchParams();
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
const qs = params.toString();
return getJson<DatasetRowsResult>(
`${DATASETS_BASE}/${encodeURIComponent(id)}/rows${qs ? `?${qs}` : ''}`,
);
}

/**
* Uploads a CSV as `multipart/form-data`. Content-Type is NOT set manually —
* the browser generates the boundary (mirrors `uploadPackage`).
*/
export async function uploadDataset(
file: File,
name: string,
): Promise<DatasetUploadResult> {
const forwarded = await forwardCookieHeader();
const form = new FormData();
form.append('file', file, file.name);
if (name.trim().length > 0) form.append('name', name.trim());
const res = await fetch(botApi(DATASETS_BASE), {
method: 'POST',
body: form,
headers: { accept: 'application/json', ...forwarded },
credentials: 'include',
cache: 'no-store',
});
const text = await res.text();
if (!res.ok) {
maybeNavigateToLogin(res.status);
throw new ApiError(res.status, `POST ${DATASETS_BASE} failed: ${res.status}`, text);
}
return JSON.parse(text) as DatasetUploadResult;
}

export async function deleteDataset(id: string): Promise<void> {
return deleteRequest(`${DATASETS_BASE}/${encodeURIComponent(id)}`);
}

// -----------------------------------------------------------------------------
// Public API keys (issues #438/#439; admin UI follow-through #567) —
// /api/public/v1/admin/keys.
Expand Down
6 changes: 5 additions & 1 deletion web-ui/app/_lib/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ export function renderWithIntl(
const { locale = 'en', ...rest } = options;
function Wrapper({ children }: { children: ReactNode }): ReactElement {
return (
<NextIntlClientProvider locale={locale} messages={MESSAGES[locale]}>
<NextIntlClientProvider
locale={locale}
messages={MESSAGES[locale]}
timeZone="UTC"
>
{children}
</NextIntlClientProvider>
);
Expand Down
Loading
Loading