npm package: @fullkontrol/ssi
Description: Type-safe TypeScript client for the fullkontrol SSI API
Latest version: 0.3.0

# @fullkontrol/ssi

Type-safe TypeScript client for the fullkontrol SSI API.

- **Fully typed** — every method parameter and return value is typed from the live OpenAPI spec
- **Lightweight** — built on [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) (~6 KB)
- **Auto-generated** — no manual type definitions to maintain
- **Universal** — ships ESM + CJS

## Install

```bash
npm install @fullkontrol/ssi
# or
bun add @fullkontrol/ssi
```

## Quick start

```ts
import { FullkontrolClient } from "@fullkontrol/ssi";

const fk = new FullkontrolClient({
  baseUrl: "https://alerts.fullkontrol.app",
  apiKey: "fk_abc123...",
});

// Send an alert — the most common operation
await fk.alert({
  workspace_id: "ws_1",
  place_id: "p_1",
  task_id: "t_1",
  to: "user_123",
  title: "Task reminder",
  body: "Your task is due in 30 minutes",
});
```

## Constructor options

| Option | Type | Description |
|--------|------|-------------|
| `baseUrl` | `string` | **Required** — scheduler API base URL |
| `apiKey` | `string` | Injected as `x-service-token: Bearer <key>` |
| `fetch` | `typeof fetch` | Custom fetch implementation (useful for testing) |

## API reference

### `fk.alert()` — top-level alert primitive

A high-level primitive for the most common operation: send an alert to someone about a task. Handles field mapping, `Date` conversion, and fan-out internally.

```ts
// Send immediately to one user
const result = await fk.alert({
  workspace_id: "ws_1",
  place_id: "p_1",
  task_id: "t_1",
  to: "user_123",
  title: "Task reminder",
  body: "Your task is due in 30 minutes",
});
// result: { to, run_id, dispatched: true, dispatch: { accepted, outcome, ... } }

// Fan-out to multiple recipients — never throws, errors inline
import { isAlertError } from "@fullkontrol/ssi";

const results = await fk.alert({
  workspace_id: "ws_1",
  place_id: "p_1",
  task_id: "t_1",
  to: ["user_1", "user_2", "user_3"],
  title: "Team alert",
  body: "Task needs attention",
});

for (const r of results) {
  if (isAlertError(r)) {
    console.error(`Failed for ${r.to}: ${r.error}`);
  } else {
    console.log(`Sent to ${r.to}: run ${r.run_id}`);
  }
}

// Schedule for later
await fk.alert({
  workspace_id: "ws_1",
  place_id: "p_1",
  task_id: "t_1",
  to: "user_123",
  title: "Task reminder",
  body: "Your task is due in 30 minutes",
  at: "2025-06-15T14:00:00Z",
});

// RRULE schedule — creates a definition + materializes runs in one call
const scheduled = await fk.alert({
  workspace_id: "ws_1",
  place_id: "p_1",
  task_id: "t_1",
  to: "user_123",
  title: "Daily standup reminder",
  body: "Standup starts in 15 minutes",
  schedule: {
    rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0",
    timezone: "Europe/Oslo",
  },
});
// scheduled.schedule: { definition_id, created, already_exists, run_ids }

// WhatsApp instead of push
await fk.alert({
  workspace_id: "ws_1",
  place_id: "p_1",
  task_id: "t_1",
  to: "user_123",
  channel: "whatsapp",
  title: "Urgent",
  body: "Task overdue",
});
```

#### `AlertInput` fields

| Field | Type | Description |
|-------|------|-------------|
| `workspace_id` | `string` | **Required** |
| `place_id` | `string` | **Required** |
| `task_id` | `string` | **Required** |
| `to` | `string \| string[]` | **Required** — recipient user ID(s). Arrays fan-out to parallel calls |
| `title` | `string` | **Required** — notification title |
| `body` | `string` | **Required** — notification body |
| `channel` | `"push" \| "whatsapp"` | Delivery channel (default `"push"`) |
| `at` | `string \| Date` | Schedule for later. Omit for immediate dispatch |
| `timezone` | `string` | IANA timezone for interpreting local `at` values |
| `schedule` | `AlertScheduleInput` | RRULE schedule — creates a definition and materializes runs server-side |

#### `AlertScheduleInput`

| Field | Type | Description |
|-------|------|-------------|
| `rrule` | `string` | **Required** — RFC 5545 RRULE string (e.g. `FREQ=DAILY;BYHOUR=9;BYMINUTE=0`) |
| `timezone` | `string` | **Required** — IANA timezone (e.g. `Europe/Oslo`) |
| `task_status` | `string` | Task status for the materialization context (default `"open"`) |
| `task_title` | `string` | Task title for the materialization context (defaults to alert title) |

When `schedule` is provided, `dispatch_immediately` is forced to `false`. The backend creates an alert definition with `trigger_type: "rrule"` and immediately materializes runs. The result includes a `schedule` object with the created `definition_id`, run counts, and `run_ids`.

### `fk.alert.update()` — update task alerts

Cancels existing alert runs for a task and re-materializes them based on updated task state. Wraps `POST /v1/planner/replan-task` into a flat, ergonomic interface.

```ts
// Task was rescheduled — cancel old runs and create new ones
const result = await fk.alert.update({
  workspace_id: "ws_1",
  place_id: "p_1",
  task_id: "t_1",
  timezone: "America/New_York",
  scheduled_at: new Date("2026-03-01T09:00:00Z"),
  task_status: "todo",
  task_title: "Morning check",
  assignee_user_id: "user_1",
});
// result: { cancelled_runs: 2, result: { created: 1, already_exists: 0, skipped: 1, run_ids: ["run_new"] }, inspected_definitions: 3 }

// Provide a reason for auditing
await fk.alert.update({
  workspace_id: "ws_1",
  place_id: "p_1",
  task_id: "t_1",
  timezone: "Europe/Oslo",
  scheduled_at: "2026-03-01T10:00:00Z",
  task_status: "open",
  task_title: "Weekly inspection",
  reason: "schedule_changed",
});
```

By default all matching definitions are replanned. Pass `definition_ids` to scope to specific ones (from `fk.definitions.list()` or `fk.definitions.create()`).

### `fk.alerts.send()` — low-level direct send

```ts
// Send an alert (dispatches immediately by default)
await fk.alerts.send({
  workspace_id: "ws_1",
  place_id: "place_1",
  task_id: "task_1",
  recipient_user_id: "user_1",
  title: "Test notification",
  body: "This is a test alert",
});

// Schedule for later (dispatch_immediately defaults to false when scheduled_for is set)
await fk.alerts.send({
  workspace_id: "ws_1",
  place_id: "place_1",
  task_id: "task_1",
  recipient_user_id: "user_1",
  title: "Scheduled test",
  body: "Will be dispatched later",
  scheduled_for: "2025-06-15T14:00:00Z",
});

// RRULE schedule (creates definition + materializes in one call)
const result = await fk.alerts.send({
  workspace_id: "ws_1",
  place_id: "place_1",
  task_id: "task_1",
  recipient_user_id: "user_1",
  title: "Daily check",
  body: "Time for your daily check",
  schedule: {
    rrule: "FREQ=DAILY;BYHOUR=10;BYMINUTE=0",
    timezone: "America/New_York",
  },
});
// result.schedule: { definition_id, created, already_exists, run_ids }
```

### `fk.definitions`

```ts
// Create a new alert definition
await fk.definitions.create({
  workspace_id: "ws_1",
  trigger_type: "relative_to_task_time",
  offsets: ["-30m"],
  condition: { allowed_statuses: ["open"], stop_statuses: ["done"] },
  recipients: { assignee: true },
  message_template: { title: "Reminder", body: "Task is due soon" },
  created_by: "admin",
});

// List definitions for a workspace
await fk.definitions.list({ workspace_id: "ws_1" });
await fk.definitions.list({ workspace_id: "ws_1", enabled: "true" });

// Update a definition
await fk.definitions.update("def_id", { enabled: false });
await fk.definitions.update("def_id", { offsets: ["-15m", "-1h"] });
```

### `fk.planner`

```ts
// Materialize alert runs for a task
await fk.planner.materialize({
  task: {
    workspace_id: "ws_1",
    place_id: "place_1",
    task_id: "task_1",
    timezone: "Europe/Oslo",
    scheduled_at: "2025-06-15T14:00:00Z",
    task_status: "open",
    task_title: "Weekly inspection",
  },
});

// Replan after a task changes (cancels existing runs + re-materializes)
await fk.planner.replanTask({
  task: {
    workspace_id: "ws_1",
    place_id: "place_1",
    task_id: "task_1",
    timezone: "Europe/Oslo",
    scheduled_at: "2025-06-16T10:00:00Z",
    task_status: "open",
    task_title: "Weekly inspection",
  },
  reason: "schedule_changed",
});
```

### `fk.runs`

```ts
// List runs with filters
await fk.runs.list({ workspace_id: "ws_1", limit: 50 });
await fk.runs.list({ workspace_id: "ws_1", status: "failed" });
await fk.runs.list({
  workspace_id: "ws_1",
  task_id: "task_1",
  from: "2025-06-01T00:00:00Z",
  to: "2025-06-30T23:59:59Z",
});

// Get a single run
const run = await fk.runs.get("run_id");

// Get delivery attempts for a run
const { attempts } = await fk.runs.attempts("run_id");
const { attempts: last5 } = await fk.runs.attempts("run_id", { limit: 5 });

// Get status transition events for a run
const { events } = await fk.runs.events("run_id");

// Cancel all pending runs for a task
await fk.runs.cancelByTask({
  workspace_id: "ws_1",
  task_id: "task_1",
  reason: "task_deleted",
});
```

### `fk.runs.deadLetter`

```ts
// List dead-lettered runs
await fk.runs.deadLetter.list({ workspace_id: "ws_1" });

// Redrive a single run (resets to planned status)
await fk.runs.deadLetter.redrive({ run_id: "run_abc" });

// Bulk redrive
await fk.runs.deadLetter.redriveBulk({
  run_ids: ["run_abc", "run_def"],
});
```

### `fk.apiKeys`

```ts
// Create a new API key (plaintext returned only once)
const { key, id } = await fk.apiKeys.create({
  label: "Firebase integration",
  created_by: "admin",
});
console.log(key); // fk_a1b2c3...

// List all keys (metadata only, no secrets)
const { keys } = await fk.apiKeys.list();

// Revoke a key
await fk.apiKeys.revoke("key_id");
```

### `fk.auth`

```ts
// Issue a short-lived service JWT
const { token, expires_at } = await fk.auth.issueToken({ ttl_seconds: 900 });

// Use defaults
const jwt = await fk.auth.issueToken();
```

## Error handling

All methods throw `FullkontrolApiError` on non-2xx responses:

```ts
import { FullkontrolClient, FullkontrolApiError } from "@fullkontrol/ssi";

try {
  await fk.definitions.update("nonexistent", { enabled: false });
} catch (e) {
  if (e instanceof FullkontrolApiError) {
    console.log(e.status);  // 404
    console.log(e.message); // "Definition not found: nonexistent"
    console.log(e.body);    // { error: "Definition not found: nonexistent" }
  }
}
```

| Status | Meaning |
|--------|---------|
| 400 | Validation error (check request body) |
| 401 | Unauthorized (missing or invalid API key) |
| 404 | Resource not found |
| 500 | Server error |
| 503 | Auth not configured / service unavailable |

## Examples

### Firebase Functions: materialize on task create

```ts
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { FullkontrolClient } from "@fullkontrol/ssi";

const fk = new FullkontrolClient({
  baseUrl: process.env.SCHEDULER_URL!,
  apiKey: process.env.SCHEDULER_API_KEY!,
});

export const onTaskCreated = onDocumentCreated(
  "workspaces/{workspaceId}/tasks/{taskId}",
  async (event) => {
    const task = event.data?.data();
    if (!task) return;

    await fk.planner.materialize({
      task: {
        workspace_id: event.params.workspaceId,
        place_id: task.place_id,
        task_id: event.params.taskId,
        timezone: task.timezone,
        scheduled_at: task.scheduled_at,
        task_status: task.status,
        task_title: task.title,
        assignee_user_id: task.assignee_id,
        owner_user_id: task.owner_id,
      },
    });
  }
);
```

### Firebase Functions: replan on task update

```ts
export const onTaskUpdated = onDocumentUpdated(
  "workspaces/{workspaceId}/tasks/{taskId}",
  async (event) => {
    const before = event.data?.before.data();
    const after = event.data?.after.data();
    if (!before || !after) return;

    const scheduleChanged = before.scheduled_at !== after.scheduled_at;
    const statusChanged = before.status !== after.status;

    if (!scheduleChanged && !statusChanged) return;

    await fk.alert.update({
      workspace_id: event.params.workspaceId,
      place_id: after.place_id,
      task_id: event.params.taskId,
      timezone: after.timezone,
      scheduled_at: after.scheduled_at,
      task_status: after.status,
      task_title: after.title,
      assignee_user_id: after.assignee_id,
      owner_user_id: after.owner_id,
      reason: scheduleChanged ? "schedule_changed" : "status_changed",
    });
  }
);
```

### Firebase Functions: cancel on task delete

```ts
export const onTaskDeleted = onDocumentDeleted(
  "workspaces/{workspaceId}/tasks/{taskId}",
  async (event) => {
    await fk.runs.cancelByTask({
      workspace_id: event.params.workspaceId,
      task_id: event.params.taskId,
      reason: "task_deleted",
    });
  }
);
```

### Dead-letter monitoring cron

```ts
import { onSchedule } from "firebase-functions/v2/scheduler";

export const checkDeadLetters = onSchedule("every 1 hours", async () => {
  const { data } = await fk.runs.deadLetter.list({ limit: 500 });

  if (data.length === 0) return;

  console.warn(`${data.length} dead-lettered runs found`);

  // Auto-redrive all
  await fk.runs.deadLetter.redriveBulk({
    run_ids: data.map((run) => run.id),
  });
});
```

### Using types for your own interfaces

```ts
import type {
  AlertDefinition,
  AlertRun,
  AlertRunAttempt,
  CreateDefinitionInput,
} from "@fullkontrol/ssi";

function formatRun(run: AlertRun): string {
  return `[${run.status}] ${run.id} scheduled for ${run.scheduled_for}`;
}
```

## Development

```bash
bun install                                      # Install deps
bun run --filter @fullkontrol/ssi typecheck      # Type check
bun run --filter @fullkontrol/ssi test           # Run tests
bun run --filter @fullkontrol/ssi build          # Build ESM + CJS + declarations
```

### Regenerating types

If the API schema changes, regenerate types from a running dev server:

```bash
# Start the scheduler
bun run --filter @fullkontrol/alerts-server dev

# Regenerate (defaults to http://localhost:8787)
bun run --filter @fullkontrol/ssi generate

# Or point to a different URL
OPENAPI_URL=https://alerts.fullkontrol.app bun run --filter @fullkontrol/ssi generate
```

The generated file at `src/generated/openapi.ts` is committed to git — consumers never need to run generation.

## License

MIT