# @qaecy/cue-sdk

Headless JavaScript SDK for building apps on the QAECY Cue platform. A framework-agnostic client that handles authentication, project management, and API access — the programmatic counterpart to the `@qaecy/cue-widget`.

## Installation

```bash
npm install @qaecy/cue-sdk firebase
```

> `firebase` is a peer dependency and must be installed alongside the SDK.

## Quick start

### Demo app
```typescript
import { Cue } from '@qaecy/cue-sdk';

// Use default demo app (prints a warning — fine for evaluation)
const cue = new Cue();

// Or supply your own configuration
const cue = new Cue({
  apiKey: 'YOUR_FIREBASE_API_KEY',
  appId: 'YOUR_FIREBASE_APP_ID',
  measurementId: 'YOUR_MEASUREMENT_ID',
});

// Listen for auth state changes
cue.auth.onAuthStateChanged((user) => {
  console.log(user ? `Signed in as ${user.displayName}` : 'Signed out');
});

// Sign in
await cue.auth.signIn('google');
// or
await cue.auth.signIn('microsoft');
// or
await cue.auth.signIn('password', { email: 'user@example.com', password: 'secret' });

// List projects
const projects = await cue.api.projects.listProjects();

// Search documents
const results = await cue.api.search({
  term: 'What are the fire safety requirements?',
  projectId: projects[0].id,
});

console.log(results.response);
console.log(results.sources);

// Sign out
await cue.auth.signOut();
```

## Configuration

| Option | Type | Required | Description |
|---|---|---|---|
| `apiKey` | `string` | — | Firebase API key (defaults to QAECY demo app) |
| `appId` | `string` | — | Firebase App ID (defaults to QAECY demo app) |
| `measurementId` | `string` | — | Firebase Measurement ID (defaults to QAECY demo app) |
| `environment` | `'production' \| 'emulator'` | — | Target environment (default: `'production'`) |
| `endpoints` | `Partial<CueEndpoints>` | — | Override individual endpoint URLs (takes precedence over `environment`) |

## Auth

### `cue.auth.signIn(provider)`

Sign in with Google or Microsoft via a browser popup:

```typescript
const user = await cue.auth.signIn('google');
const user = await cue.auth.signIn('microsoft');
```

### `cue.auth.signIn('password', credentials)`

Sign in with email and password:

```typescript
const user = await cue.auth.signIn('password', {
  email: 'user@example.com',
  password: 's3cr3t',
});
```

### `cue.auth.signInWithApiKey(cueApiKey, projectId)`

Sign in using a Cue API key. Intended for non-interactive/server-side use (e.g. via `CueNode`):

```typescript
const user = await cue.auth.signInWithApiKey('MY_CUE_API_KEY', 'my-project-id');
```

### `cue.auth.signInWithCustomToken(token)`

Sign in using a Firebase custom token. Intended for server-issued auth flows such as MCP tools or OAuth-protected backends where the server mints a token on behalf of the user.

**Typical flow (e.g. MCP + Google OAuth):**

1. The server receives a Google OAuth access token (e.g. via the MCP host's OAuth dance).
2. The server looks up the Firebase UID for the Google `sub` claim and mints a custom token using Firebase Admin:
   ```typescript
   // Server side (Node.js / Firebase Admin)
   import * as admin from 'firebase-admin';

   async function mintCustomToken(googleAccessToken: string): Promise<string> {
     const res = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
       headers: { Authorization: `Bearer ${googleAccessToken}` },
     });
     const { sub } = await res.json(); // Google UID

     const user = await admin.auth().getUserByProviderUid('google.com', sub);
     return admin.auth().createCustomToken(user.uid);
   }
   ```
3. The custom token is passed to the browser/view (e.g. via `structuredContent`).
4. The client signs in instantly — no popup, no redirect, no polling:
   ```typescript
   // Browser / view side
   const cue = new Cue();
   await cue.auth.signInWithCustomToken(customToken);
   // cue is now fully authenticated
   ```

### `cue.auth.signOut()`

```typescript
await cue.auth.signOut();
```

### `cue.auth.currentUser`

Returns the currently signed-in Firebase `User`, or `null`.

### `cue.auth.onAuthStateChanged(listener)`

Subscribe to auth state changes. Returns an unsubscribe function.

```typescript
const unsubscribe = cue.auth.onAuthStateChanged((user) => {
  if (user) startApp(user);
});

// Later:
unsubscribe();
```

### `cue.auth.getToken(forceRefresh?)`

Get the Firebase ID token for the current user:

```typescript
const token = await cue.auth.getToken();
```

## API

All API methods require the user to be authenticated first.

### `cue.api.search(request)`

Search project documents using natural language:

```typescript
const results = await cue.api.search({
  term: 'fire resistance requirements',
  projectId: 'my-project-id',
  categories: ['buildings', 'regulations'], // optional
});

// results.response  — AI-generated answer
// results.sources   — source documents
```

### `cue.api.sparql(query, projectId)`

Execute a SPARQL query against the project's triplestore:

```typescript
const data = await cue.api.sparql(
  `SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10`,
  'my-project-id',
);
```

### `cue.api.language` / `cue.api.setLanguage(lang)`

Active language used for language-sensitive SPARQL label queries (e.g. schema category labels and document text fields). Defaults to `'en'`. All project classes (`CueProjectSchema`, `CueProjectDocuments`, `CueProjectEntities`) read this shared value at query time.

```typescript
cue.api.setLanguage('da');
console.log(cue.api.language); // 'da'
```

When using `CueProjectView`, prefer calling `view.setLanguage(lang)` — it updates `api.language` **and** reloads language-sensitive cached data (schema labels, document subjects/summaries) for the new language.

## Projects

Manage Cue projects via `cue.api.projects` (`CueProjects`).

### `cue.api.projects.listProjects()`

List all projects where the authenticated user is a member, syncer, or admin:

```typescript
const projects = await cue.api.projects.listProjects();
projects.forEach((p) => console.log(p.id, p.name));
```

### `cue.api.projects.getProject(projectId)`

Fetch a single project by ID. Returns `null` if not found:

```typescript
const project = await cue.api.projects.getProject('my-project-id');
```

### `cue.api.projects.createProject(options)`

Create a new project. The authenticated user is automatically set as admin, syncer, and member:

```typescript
const project = await cue.api.projects.createProject({
  name: 'My Project',
  organizationID: 'my-org-id',
  id: 'optional-custom-id', // defaults to a new UUID
});
```

## Entities

Entity data for a project is accessed via a `CueProjectView`. Create a view with `cue.createProjectView(projectId)` and use `view.entities` (`CueProjectEntities`) for all entity-level operations.

### `view.entities.entitiesByCategory(categoryIris, includeMetadata?)`

Fetch all canonical entities that belong to at least one of the given category IRIs. Accepts both full HTTP IRIs and prefixed forms.

By default returns `{ iri, uuid }[]` — the IRI is built locally from the project base URL so no extra data is fetched from the endpoint. Pass `true` to also receive `value` and `categories`.

```typescript
// Lean — iri + uuid, no extra SPARQL traffic (default)
const entities = await view.entities.entitiesByCategory([
  'qcy:Building',
  'qcy:BuildingStorey',
]);
view.entities.requestEntityData(entities.map((e) => e.uuid));

// With metadata
const entities = await view.entities.entitiesByCategory(
  ['https://cue.qaecy.com/ontology#Building'],
  true,
);
entities.forEach((e) => console.log(e.uuid, e.value, e.categories));
```

| Parameter | Type | Description |
|---|---|---|
| `categoryIris` | `string[]` | Full IRIs (`https://…`) or prefixed forms (`qcy:…`) |
| `includeMetadata` | `boolean` | `false` (default) → `{ iri, uuid }[]`; `true` → adds `value` and `categories` |

### `view.entities.contentCategoriesInProject(orderByOccurrences?)`

Fetch all `qcy:EntityCategory` IRIs present in this project with their preferred labels:

```typescript
const cats = await view.entities.contentCategoriesInProject();
cats.forEach((c) => console.log(c.iri, c.label));
```

### `view.entities.buildSummaryGraph(format?)`

Fetches a project-level summary of entity category relationships: how many times entities of one category point to entities of another via each predicate, ordered by descending occurrence count.

| Parameter | Type | Description |
|---|---|---|
| `format` | `'graph'` \| `'md'` \| `undefined` | `'graph'` returns structured nodes + edges (`SummaryGraphData`); `'md'` returns a compact aligned text table; omit for the raw SPARQL JSON result |

```typescript
// Structured graph — nodes and edges separated
const g = await view.entities.buildSummaryGraph('graph');
// g.entities  → [{ iri: 'https://…FloorPlanDrawing' }, …]
// g.relations → [{ sourceID, predicate, targetID, weight }, …]

// Compact markdown table
const md = await view.entities.buildSummaryGraph('md');
// qcy:FloorPlanDrawing -> qcy:includesBuildingEntity -> qcy:BuildingZone     (5652)
// qcy:Activity         -> qcy:involvesBuildingEntity -> qcy:BuildingElement  (4003)
// qcy:BuildingElement  -> qcy:elementHasMaterial     -> qcy:Material         (3551)

// Raw SPARQL JSON result
const raw = await view.entities.buildSummaryGraph();
```

## Documents

Document data for a project is accessed via `view.documents` (`CueProjectDocuments`). Use `fetchOverview()` to get aggregate counts and the methods below to retrieve document lists.

### `view.documents.documentsBySuffix(suffixes, includeMetadata?)`

Fetch all documents whose file extension matches one of the given suffixes. The leading dot is optional — both `'ifc'` and `'.ifc'` are accepted.

By default returns `{ iri, uuid }[]` — the IRI is built locally from the project base URL, no extra data fetched. Pass `true` to also receive `path`, `suffix`, and `size`.

```typescript
// Lean — iri + uuid, no extra SPARQL traffic (default)
const docs = await view.documents.documentsBySuffix(['.ifc', '.rvt']);
view.documents.requestDocumentData(docs.map((d) => d.uuid));

// With file metadata
const docs = await view.documents.documentsBySuffix(['pdf', 'docx'], true);
docs.forEach((d) => console.log(d.path, d.suffix, d.size));
```

### `view.documents.documentsByFileType(fileTypes, includeMetadata?)`

Fetch documents by semantic `FileType` category. Resolves all matching suffixes automatically from the built-in `fileExtensionsInfo` map.

```typescript
import { FileType } from 'js/models';

// All BIM and CAD files — iri + uuid
const docs = await view.documents.documentsByFileType([
  FileType.BIM,
  FileType.CAD,
]);

// With metadata
const docs = await view.documents.documentsByFileType([FileType.IMAGE], true);
```

### `view.documents.documentsByMime(mimeTypes, includeMetadata?)`

Fetch documents whose MIME type matches one of the given strings:

```typescript
// Just iri + uuid
const docs = await view.documents.documentsByMime([
  'application/x-step',  // .ifc
  'application/pdf',
]);

// With metadata
const docs = await view.documents.documentsByMime(
  ['image/png', 'image/jpeg'],
  true,
);
```

| Method | Filters by | Default return | With `true` |
|---|---|---|---|
| `documentsBySuffix` | File extension string(s) | `{ iri, uuid }[]` | adds `path`, `suffix`, `size` |
| `documentsByFileType` | `FileType` enum value(s) | `{ iri, uuid }[]` | adds `path`, `suffix`, `size` |
| `documentsByMime` | MIME type string(s) | `{ iri, uuid }[]` | adds `path`, `suffix`, `size` |

## Node.js — file sync

For server-side or CLI use with file-sync capabilities, import from `@qaecy/cue-sdk/node`:

```typescript
import { CueNode } from '@qaecy/cue-sdk/node';

const cue = new CueNode({
  apiKey: 'YOUR_FIREBASE_API_KEY',
  appId: 'YOUR_FIREBASE_APP_ID',
  measurementId: 'YOUR_MEASUREMENT_ID',
});

// Use an API key for non-interactive sign-in
await cue.auth.signInWithApiKey('MY_CUE_API_KEY', 'my-project-id');

// Sync local files into a project
const result = await cue.api.sync.sync(localFiles, {
  spaceId: 'my-project-id',
  providerId: 'my-provider',
  userId: cue.auth.currentUser!.uid,
  verbose: true,
});

console.log(`Synced ${result.syncCount} files (${result.failedUploads} failed)`);
```

### `cue.api.sync.sync(localFiles, options)`

Compares local files against the remote project and uploads any that are missing or changed.

| Option | Type | Description |
|---|---|---|
| `spaceId` | `string` | Project ID to sync into |
| `providerId` | `string` | Identifier for the file source (e.g. `'local'`) |
| `userId` | `string` | Authenticated user ID |
| `verbose` | `boolean` | Enable progress logging (default: `false`) |

Returns a `SyncResult`:

| Field | Type | Description |
|---|---|---|
| `syncCount` | `number` | Files successfully synced |
| `syncSize` | `number` | Bytes synced |
| `failedUploads` | `number` | Files that failed to upload |
| `totalCount` | `number` | Total file count (local + remote) |
| `totalSize` | `number` | Total size across all files |
| `rdfWritten` | `boolean` | Whether any RDF metadata was written |

## GIS

Reactive GIS service for querying spatial features within a map viewport. Accessed via the lazy getter `cue.gis` — the `CueGis` instance is created on first access and reused for the lifetime of the `Cue` object.

The `js/cue-gis` library (gateway routing, adapters, OpenStreetMap, Matrikel, etc.) is **bundled inside the SDK** — no extra install needed.

### Quick example

```typescript
const gis = cue.gis;

// Set the active project (drives the Cue-authenticated data source)
gis.setProjectId('my-project-id');

// Subscribe to results
const unsubCats = gis.onAvailableCategories((cats) => {
  console.log('Available categories:', cats.map(c => c.label));
});

const unsubFeatures = gis.onFeaturesChange((featuresMap) => {
  for (const [category, features] of featuresMap) {
    console.log(`${category}: ${features.length} features`);
  }
});

gis.onLoadingChange((loading) => console.log('Loading:', loading));

// On every map pan/zoom (e.g. from a Mapbox moveend event):
map.on('moveend', () => {
  const b = map.getBounds();
  gis.setBbox([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
});

// When the user toggles a category:
gis.setSelectedCategories(new Set(['cadastre', 'building']));

// Cleanup (e.g. on sign-out or component destroy):
unsubCats();
unsubFeatures();
gis.destroy();
```

### `cue.gis.setProjectId(projectId)`

Set the active project. Triggers a new query when a bbox is set. Pass `null` to clear.

```typescript
cue.gis.setProjectId('my-project-id');
cue.gis.setProjectId(null); // clear
```

### `cue.gis.setBbox(bbox)`

Set the current map viewport as `[west, south, east, north]` (WGS-84). Triggers a debounced query (1.5 s) to list available categories and reload selected features.

```typescript
cue.gis.setBbox([8.5, 47.3, 8.6, 47.4]);
```

### `cue.gis.setSelectedCategories(categories)`

Replace the full set of selected categories. Features for newly selected categories are fetched immediately; deselected ones are dropped from the results.

```typescript
cue.gis.setSelectedCategories(new Set(['cadastre', 'building', 'greenspace']));
```

### `cue.gis.onAvailableCategories(callback)` → unsubscribe

Subscribe to the list of feature-category descriptors available in the current viewport. Replays the current value immediately.

```typescript
const unsub = cue.gis.onAvailableCategories((cats) => {
  // cats: GisCategoryDescriptor[]
  renderCategoryPanel(cats);
});
```

### `cue.gis.onFeaturesChange(callback)` → unsubscribe

Subscribe to the live feature map (category → features). Replays the current value immediately.

```typescript
const unsub = cue.gis.onFeaturesChange((map) => {
  // map: Map<FeatureCategory, GisFeature[]>
  for (const [cat, features] of map) renderLayer(cat, features);
});
```

### `cue.gis.onLoadingChange(callback)` → unsubscribe

Subscribe to the global loading state. `true` while any query is in flight.

```typescript
const unsub = cue.gis.onLoadingChange((loading) => showSpinner(loading));
```

### `cue.gis.destroy()`

Cancel all in-flight requests, clear all listeners, and reset internal state. Call on sign-out or component teardown.

### Types

| Type | Description |
|---|---|
| `GisBBox` | `[west, south, east, north]` — WGS-84 decimal degrees |
| `FeatureCategory` | `'address' \| 'poi' \| 'railway' \| 'natural' \| 'manmade' \| 'cadastre' \| 'building' \| 'greenspace' \| 'paved' \| 'zone'` |
| `GisCategoryDescriptor` | `{ category, label, description, preferredColor }` |
| `GisFeature` | Full feature record with geometry, properties, tier, source info |
| `GisFeaturesMap` | `Map<FeatureCategory, GisFeature[]>` |
| `CueGis` | The class exposed via `cue.gis` |

## Advanced

Access the raw Firebase `Auth` instance for advanced use cases:

```typescript
const firebaseAuth = cue.auth.firebaseAuth;
```

## E2E tests

The SDK ships integration tests in `e2e/` that run against the local Firebase emulator stack.

### Prerequisites

Start the frontend emulator stack from the `e2e` repo (requires Docker):

```bash
# From /path/to/e2e
docker compose up firebase-emulator frontend-seed
```

This spins up Firebase Auth, Firestore, and Storage emulators and seeds the following password-enabled test accounts:

| Email | Password | Role |
|-------|----------|------|
| `front-regular@example.com` | `Test1234!` | member |
| `front-super@example.com` | `Test1234!` | superadmin |

### Run

```bash
npx nx run js-cue-sdk:e2e
```

Or directly with vitest (from `libs/js/cue-sdk/`):

```bash
npx vitest run --config vitest.e2e.config.mts
```

### What is tested

| Suite | Tests |
|-------|-------|
| `CueAuth — reactive signals` | `user`, `token`, `isSuperAdmin` (false), `checkSuperAdmin()`, `userIds` |
| `CueProfile — password management` | `getSignInMethods`, `updatePassword` → sign-in with new pw, old pw rejected, restore |
| `CueAuth — superadmin claim` | `isSuperAdmin` signal (true), `checkSuperAdmin()` (true) |

---

## License

MIT
