# @timeback/oneroster

TypeScript client for the OneRoster 1.2 API.

## Installation

```bash
bun add @timeback/oneroster
```

## Quick Start

```typescript
import { OneRosterClient } from '@timeback/oneroster'

const client = new OneRosterClient({
	env: 'production',
	auth: {
		clientId: 'your-client-id',
		clientSecret: 'your-client-secret',
	},
})

const students = await client.students.list()
const user = await client.users.get('user-sourced-id')
const course = await client.courses.create({
	title: 'Algebra I',
	org: { sourcedId: 'school-id' },
})
```

## API Design

### Standalone vs Composed

The client works standalone or composed into `@timeback/core`:

```typescript
// Composed
import { TimebackClient } from '@timeback/core'

// Standalone
const oneroster = new OneRosterClient({ env: 'production', auth })

const client = new TimebackClient({ baseUrl, auth })
client.oneroster.users.list()
```

### Client Structure

```typescript
const client = new OneRosterClient(options)

// Rostering
client.users // Users (all roles)
client.students // Students (filtered users)
client.teachers // Teachers (filtered users)
client.classes // Classes
client.courses // Courses
client.enrollments // Enrollments
client.orgs // Organizations
client.schools // Schools (filtered orgs)
client.terms // Academic terms
client.academicSessions
client.gradingPeriods
client.demographics

// Gradebook
client.results // Grade results
client.lineItems // Line items (assignments)
client.categories // Grading categories
client.scoreScales // Score scales
client.assessmentLineItems
client.assessmentResults

// Resources
client.resources
```

Resources serve dual purposes — as properties for top-level operations, and as functions for accessing nested resources:

```typescript
// As property → top-level CRUD
client.schools.list()
client.schools.get('id')

// As function → scoped operations
client.schools('id').classes()
client.schools('id').students()
```

### Common Methods

Each resource supports:

```typescript
// List with type-safe filtering
const activeUsers = await client.users.list({
	where: { status: 'active' },
	sort: 'familyName',
	orderBy: 'asc',
})

// Filter with operators
const teachers = await client.users.list({
	where: { role: 'teacher', status: 'active' },
})

// Stream for large datasets (lazy pagination)
for await (const user of client.users.stream()) {
	console.log(user.givenName)
}

// Get by sourcedId
client.users.get('sourced-id')

// Existence checks
const exists = await client.users.exists('sourced-id') // callable form
const scopedExists = await client.resources('resource-id').exists() // scoped form

// Create (where supported)
client.courses.create({ title: 'Math 101', org: { sourcedId: '...' } })

// Update (where supported)
client.courses.update('sourced-id', { title: 'Math 102' })

// Delete (where supported)
client.courses.delete('sourced-id')

// Patch (enrollments, assessmentLineItems, assessmentResults)
client.enrollments.patch('sourced-id', { status: 'tobedeleted' })
```

### Nested Resources

The API exposes many nested relationships:

```typescript
// ── Schools ──────────────────────────────────────────────────────────────────
client.schools('school-id').classes()
client.schools('school-id').courses()
client.schools('school-id').terms()
client.schools('school-id').teachers()
client.schools('school-id').students()
client.schools('school-id').enrollments()
client.schools('school-id').scoreScales()
client.schools('school-id').lineItems()

// Deeply nested
client.schools('school-id').classes('class-id').teachers()
client.schools('school-id').classes('class-id').students()
client.schools('school-id').classes('class-id').enrollments()

// ── Classes ──────────────────────────────────────────────────────────────────
client.classes('class-id').teachers()
client.classes('class-id').students()
client.classes('class-id').lineItems()
client.classes('class-id').results()
client.classes('class-id').categories()
client.classes('class-id').scoreScales()
client.classes('class-id').resources()
client.classes('class-id').enroll('user-id', 'student')

// Deeply nested gradebook
client.classes('class-id').lineItems('line-item-id').results()
client.classes('class-id').students('student-id').results()

// ── Users ────────────────────────────────────────────────────────────────────
client.users('user-id').classes()
client.users('user-id').demographics()
client.users('user-id').agents()
client.users('user-id').addAgent('agent-id')
client.users('user-id').removeAgent('agent-id')
client.users('user-id').agentFor()
client.users('user-id').credentials()
client.users('user-id').registerCredential()
client.users('user-id').credentials('cred-id').decrypt()

// ── Students/Teachers ────────────────────────────────────────────────────────
client.students('student-id').classes()
client.teachers('teacher-id').classes()

// ── Courses ──────────────────────────────────────────────────────────────────
client.courses('course-id').classes()
client.courses('course-id').resources()
client.courses.components()
client.courses.getComponent('component-id')
client.courses.componentResources()
client.courses.getComponentResource('id')
client.courses.createStructure({ ... })

// ── Terms ────────────────────────────────────────────────────────────────────
client.terms('term-id').classes()
client.terms('term-id').gradingPeriods()

// ── Line Items ───────────────────────────────────────────────────────────────
client.lineItems('line-item-id').results()

// ── Resources ────────────────────────────────────────────────────────────────
client.resources.list()
client.resources.create({ ... })
client.resources.exists('resource-id')
client.resources('resource-id').exists()
client.resources('resource-id').export()
```

`exists()` semantics are strict:

- 2xx responses return `true`
- 404 returns `false`
- all other failures (401/403/422/5xx, network/timeouts) throw

### Response Types

```typescript
// List responses include pagination info
const response = await client.users.list()
// {
//   users: User[],
//   offset: number,
//   limit: number,
// }

// Single resource responses
const user = await client.users.get('id')
// { user: User }

// Create responses
const created = await client.courses.create(payload)
// { sourcedIdPairs: { suppliedSourcedId, allocatedSourcedId } }
```

### Authentication

The client handles OAuth2 token management automatically:

```typescript
const client = new OneRosterClient({
	baseUrl: 'https://api.timeback.dev',
	auth: {
		clientId: 'xxx',
		clientSecret: 'xxx',
		// Optional: custom token endpoint
		authUrl: 'https://auth.example.com/oauth2/token',
	},
})
```

Tokens are cached and refreshed automatically before expiry.

### Error Handling

```typescript
import { OneRosterError } from '@timeback/oneroster'

try {
	await client.users.get('invalid-id')
} catch (error) {
	if (error instanceof OneRosterError) {
		console.log(error.status) // 404
		console.log(error.message) // 'Not Found'
		console.log(error.imsx_codeMajor) // 'failure'
	}
}
```

## Configuration

```typescript
new OneRosterClient({
  // Environment-based (recommended)
  env: 'production' | 'staging',
  auth: {
    clientId: string,
    clientSecret: string,
  },

  // Or explicit URLs
  baseUrl: string,
  auth: {
    clientId: string,
    clientSecret: string,
    authUrl?: string,
  },

  // Optional
  timeout?: number,      // Request timeout in ms (default: 30000)
})
```

## Debug Mode

Enable debug logging by setting `DEBUG=1` or `DEBUG=true`:

```bash
DEBUG=1 bun run my-script.ts
```

This outputs detailed logs for HTTP requests, authentication, and pagination:

```bash
[2025-01-15T10:30:00.000Z] DEBUG [oneroster:auth] Fetching new access token...
[2025-01-15T10:30:00.500Z] DEBUG [oneroster:auth] Token acquired (500ms, expires in 3600s)
[2025-01-15T10:30:00.501Z] DEBUG [oneroster:http] → GET https://api.example.com/ims/oneroster/.../schools
[2025-01-15T10:30:00.800Z] DEBUG [oneroster:http] ← 200 OK (299ms)
[2025-01-15T10:30:00.801Z] DEBUG [oneroster:pagination] First page: 5 items, total: 20, hasMore: true
```
