# Backend Plan: QR Login Polling Endpoint for Roku

> **Context**: The Samsung TV uses Socket.io for real-time push of login results. Roku cannot use WebSockets, so the Roku app will poll a REST endpoint every 3 seconds to check if the user completed phone authentication.

---

## Current State (How It Works Today)

```
┌──────────┐     POST /auth/request-code      ┌──────────┐
│  TV/App  │ ────────────────────────────────► │  Backend │
│ (Samsung)│ ◄────── { code: "12345678" } ──── │          │
└──────────┘                                    │          │
      │                                         │  Redis   │
      │  Socket.io connect(?device_id=X)        │          │
      │◄──────────────────────────────────────► │  ┌─────┐ │
      │  Listen: code-verified-X                │  │ key │ │
      │                                         │  └─────┘ │
      │                              ┌──────────┐          │
      │  POST /auth/verify-code      │  Phone   │          │
      │  { code, email } ◄────────── │  (web)   │          │
      │                              └──────────┘          │
      │◄── Socket.io emit ────────── signIn() ────────────►│
      │    code-verified-X                                 │
      │    { user, token }                                 │
└──────────┘                                    └──────────┘
```

### Key Redis Keys

| Key Pattern | Contents | TTL | Purpose |
|-------------|----------|-----|---------|
| `code-device-id:{device_id}` | `{ code, device_id, device_brand, device_model, guest_id, app_version, ip }` | 5 min | Maps device → code payload |
| `code:{code}` | Same as above | 5 min | Maps code → device payload |

### Key Code Paths

**`requestCode()`** (`auth.service.ts:276-295`):
```typescript
async requestCode(device_id, device_brand, device_model, guest_id?, appVersion?, ip?) {
  const key = `code-device-id:${device_id}`;
  const existingCode = await this.redisService.getKey(key);
  if (existingCode) return JSON.parse(existingCode).code; // Reuse existing
  const code = generateUniqueNumber(8);
  await this.redisService.setKey(key, JSON.stringify({ code, device_id, ... }), 60 * 5);
  await this.redisService.setKey(`code:${code}`, JSON.stringify({ code, device_id, ... }), 60 * 5);
  return code;
}
```

**`verifyCode()`** (`auth.service.ts:298-318`):
```typescript
async verifyCode(code: number, email: string) {
  const cachedItem = JSON.parse(await this.redisService.getKey(`code:${code}`));
  // ... signIn() ...
  await this.authGateway.sendCodeVerified(device_id, data);  // Socket.io emit
  await this.redisService.deleteKey(`code:${code}`);
  await this.redisService.deleteKey(`code-device-id:${device_id}`);
}
```

**`AuthSubscriberService`** (`auth.subscriber.service.ts`):
- Listens on Redis keyspace notifications `__keyevent@0__:expired`
- When `code-device-id:{id}` expires → emits `{ expired: true }` via Socket.io

---

## Problem: Roku Can't Use Socket.io

Roku's `roUrlTransfer` supports HTTP/HTTPS only. There's no built-in WebSocket client. The Roku app needs to poll a REST endpoint to discover when the user has completed phone auth.

After `verifyCode()` completes, both Redis keys are **deleted**. There's no persisted state a polling endpoint could read to return the login result.

---

## Solution: Add Result Caching + Polling Endpoint

### Approach

Add a third Redis key (`code-result:{device_id}`) with a short TTL that stores the login result after `verifyCode()`. A new polling endpoint reads this key.

```
┌──────────┐     POST /auth/request-code      ┌──────────┐
│  Roku    │ ────────────────────────────────► │  Backend │
│  TV      │ ◄────── { code: "12345678" } ──── │          │
└──────────┘                                    │  Redis   │
      │                                         │          │
      │  POST /auth/check-code-status           │  ┌─────┐ │
      │  { device_id } ──────────────────────►  │  │ keys│ │
      │  ◄── { status: "pending" }             │  │  3  │ │
      │  (every 3s)                             │  └─────┘ │
      │                                         │          │
      │                              ┌──────────┐          │
      │  POST /auth/verify-code      │  Phone   │          │
      │  { code, email } ◄────────── │  (web)   │          │
      │                              └──────────┘          │
      │                                         │          │
      │  POST /auth/check-code-status           │          │
      │  { device_id } ──────────────────────►  │          │
      │  ◄── { user, token }  ← reads code-result:{id}     │
      │                                         │          │
      │  🎉 Login!                               │          │
└──────────┘                                    └──────────┘
```

---

## Changes Required

### 1. New DTO: `CheckCodeStatusDto`

**File:** `src/modules/auth/dto/signin.dto.ts` — append:

```typescript
export class CheckCodeStatusDto {
  @ApiProperty()
  device_id: string;
}
```

---

### 2. New Service Method: `checkCodeStatus()`

**File:** `src/modules/auth/auth.service.ts` — add method:

```typescript
@Trace()
async checkCodeStatus(device_id: string) {
  // 1. Check if login result is cached (user completed auth on phone)
  const resultKey = `code-result:${device_id}`;
  const cachedResult = await this.redisService.getKey(resultKey);

  if (cachedResult) {
    const loginData = JSON.parse(cachedResult);
    // Delete after reading — one-time consumption
    await this.redisService.deleteKey(resultKey);
    return loginData;
  }

  // 2. Check if code still exists (still waiting for user)
  const codeKey = `code-device-id:${device_id}`;
  const existingCode = await this.redisService.getKey(codeKey);

  if (existingCode) {
    return { status: 'pending' };
  }

  // 3. Neither key exists — code expired or already used
  return { expired: true };
}
```

**Logic:**
| Condition | Returns |
|-----------|---------|
| `code-result:{device_id}` exists | `{ user: {...}, token: {...} }` + deletes the key |
| `code-device-id:{device_id}` exists | `{ status: "pending" }` |
| Neither exists | `{ expired: true }` |

---

### 3. Modify `verifyCode()` to Store Result in Redis

**File:** `src/modules/auth/auth.service.ts` — lines 298-318, add one `setKey` call:

```typescript
async verifyCode(code: number, email: string) {
  const key = `code:${code}`;

  const existingCode = await this.redisService.getKey(key);
  if (!existingCode) throw new BadRequestException('Invalid or expired code');

  const cachedItem = JSON.parse(existingCode);
  const { device_id, device_brand, device_model, guest_id, app_version, ip } =
    cachedItem;

  const userWithoutPassword = {
    email,
    guest_id,
  };

  const data = await this.signIn(
    userWithoutPassword,
    device_id,
    device_brand,
    device_model,
    undefined,
    app_version,
    ip,
  );

  // Existing: emit via Socket.io for Samsung/FireTV
  await this.authGateway.sendCodeVerified(device_id, data);

  // NEW: Store result for polling clients (Roku)
  // Short TTL (30s) — the TV should pick it up within 1-2 poll cycles
  await this.redisService.setKey(
    `code-result:${device_id}`,
    JSON.stringify(data),
    30, // 30 seconds TTL
  );

  await this.redisService.deleteKey(key);
  await this.redisService.deleteKey(`code-device-id:${device_id}`);
}
```

**Why 30-second TTL?**
- Roku polls every 3 seconds → will pick up the result within 3-6 seconds
- 30s provides 10× buffer for network latency
- Prevents stale login data from lingering if Roku never polls

---

### 4. Maintain Backward Compatibility: Emit `expired` via Both Channels

**File:** `src/modules/auth/auth.subscriber.service.ts` — the existing Redis keyspace notification handler already emits `{ expired: true }` via Socket.io when `code-device-id:{id}` expires. No change needed — Socket.io clients (Samsung) still work.

For Roku, the polling endpoint returns `{ expired: true }` when `code-device-id:{id}` is missing (auto-expired or deleted after successful `verifyCode`).

---

### 5. New Controller Endpoint: `POST /auth/check-code-status`

**File:** `src/modules/auth/auth.controller.ts` — add method:

```typescript
@ApiOperation({ summary: 'Check QR code verification status (for Roku polling)' })
@ApiBody({ type: CheckCodeStatusDto })
@Post('/check-code-status')
async checkCodeStatus(@Request() req: any): Promise<any> {
  const { device_id } = req.body;

  if (!device_id) {
    throw new BadRequestException('device_id is required');
  }

  return this.authService.checkCodeStatus(device_id);
}
```

Also add the import:
```typescript
import { CheckCodeStatusDto } from './dto/signin.dto';
```

> Note: The existing import line already imports from `'./dto/signin.dto'`:
> ```typescript
> import {
>   GenerateOtp,
>   RequestCode,
>   SignInDto,
>   SignInWithOtpDto,
>   SignOutDto,
>   VerifyCode,
> } from './dto/signin.dto';
> ```
> Just add `CheckCodeStatusDto` to the destructured imports.

---

### 6. Update `VerifyCode` DTO (Optional — Pre-existing Issue)

The `VerifyCode` DTO currently doesn't include `email`, but the controller destructures it:

```typescript
const { code, email } = req.body;
```

This works because NestJS validates against the DTO but doesn't strip unknown fields with default config. If validation is ever tightened, this would break. Recommended fix:

```typescript
export class VerifyCode {
  @ApiProperty()
  code: number;

  @ApiProperty()
  email: string;

  // These fields are in the cached Redis payload, not sent by the client
  // They are resolved server-side from the code lookup
}
```

> This is optional and can be done separately. The `device_id`, `device_brand`, `device_model` fields in the current DTO are misleading — they're not sent by the phone client, they're extracted from the Redis cache.

---

## Files Changed Summary

| File | Change |
|------|--------|
| `src/modules/auth/dto/signin.dto.ts` | Add `CheckCodeStatusDto` class |
| `src/modules/auth/auth.service.ts` | Add `checkCodeStatus()` method; add 1 line to `verifyCode()` for Redis result caching |
| `src/modules/auth/auth.controller.ts` | Add `POST /auth/check-code-status` endpoint |

**No changes to:**
- `auth.module.ts` — same service, no new dependencies
- `auth.gateway.ts` — Socket.io unchanged
- `auth.subscriber.service.ts` — expiry handling unchanged (Roku gets `expired` from the polling endpoint)
- Redis service — reuse existing `getKey`/`setKey`/`deleteKey`

---

## API Contract

### `POST /auth/check-code-status`

**Request:**
```json
{
  "device_id": "abc123-def456-..."
}
```

**Responses:**

| State | HTTP | Body |
|-------|------|------|
| Still waiting | 200 | `{ "status": "pending" }` |
| Login success | 200 | `{ "user": { "id": "...", "email": "...", "status": "...", "role": "...", "auth_type": "apps", "device_id": "..." }, "token": { "access_token": "...", "expires_in": "..." } }` |
| Login success (device limit) | 200 | `{ "user": {...}, "message": "Maximum allowed devices reached", "token": {...} }` |
| Code expired | 200 | `{ "expired": true }` |
| Missing device_id | 400 | `{ "statusCode": 400, "message": "device_id is required" }` |

---

## Edge Cases

### 1. Roku polls after code expires but before `verifyCode`
- `code-device-id:{id}` expired in Redis → `expired: true` returned
- Roku refetches a new code via `POST /auth/request-code`
- ✅ Handled

### 2. Roku polls during the gap between `verifyCode` setting `code-result` and deleting `code-device-id`
- `code-result:{id}` exists → login data returned immediately
- Key is deleted after consumption → subsequent polls return `expired: true`
- ✅ Handled (one-time delivery)

### 3. Samsung AND Roku both polling simultaneously
- Samsung: Socket.io push works as before
- Roku: Polling endpoint works independently
- Both can coexist — the `code-result` key is a one-time read (deleted after consumption)
- If Samsung picks up via Socket.io first, Roku still has the `code-result` key (30s TTL)
- ✅ Handled — both get the login data

### 4. Roku misses the `code-result` TTL window (>30s)
- The `code-result` key expires after 30s
- Roku gets `expired: true` → refetches new code
- User needs to scan/enter the code again
- This is acceptable — 30s is generous for a 3s polling interval
- ✅ Handled — safe failure mode

### 5. Race condition: Roku polls right as `code-result` is being set
- The `setKey` and the polling `getKey` are atomic in Redis
- The polling client either gets the result or it gets `pending` and picks it up on the next poll (3s later)
- ✅ Handled by atomic Redis operations

### 6. Code reuse attack
- `code-result` is deleted immediately after the first successful read
- `code:...` and `code-device-id:...` are deleted by `verifyCode`
- ✅ One-time use guaranteed

---

## Testing

### Unit Tests (add to `auth.service.spec.ts` and `auth.controller.spec.ts`)

1. **`checkCodeStatus` — returns login data when result is cached**
   - Mock `redisService.getKey` to return `JSON.stringify({ user: {...}, token: {...} })` for `code-result:test-device`
   - Assert returns the login payload
   - Assert `redisService.deleteKey` was called with `code-result:test-device`

2. **`checkCodeStatus` — returns pending when code exists**
   - Mock `redisService.getKey` to return `null` for `code-result` key
   - Mock `redisService.getKey` to return `JSON.stringify({ code: "12345678" })` for `code-device-id` key
   - Assert returns `{ status: "pending" }`

3. **`checkCodeStatus` — returns expired when neither key exists**
   - Mock both `getKey` calls to return `null`
   - Assert returns `{ expired: true }`

4. **`checkCodeStatus` — throws 400 when device_id is missing**
   - Assert `BadRequestException` is thrown

5. **`verifyCode` — stores result in Redis**
   - Mock signIn to return `{ user, token }`
   - Assert `redisService.setKey` is called with `code-result:{device_id}`, the serialized data, and TTL 30

### Integration Test (manual or e2e)

1. Call `POST /auth/request-code` with device_id → get code
2. Call `POST /auth/check-code-status` → expect `{ status: "pending" }`
3. Call `POST /auth/verify-code` with code + email
4. Call `POST /auth/check-code-status` → expect `{ user, token }`
5. Call `POST /auth/check-code-status` again → expect `{ expired: true }`
6. Wait 5+ minutes → call `POST /auth/check-code-status` → expect `{ expired: true }`

---

## Effort Estimate

| Task | Est. |
|------|------|
| Add `CheckCodeStatusDto` to DTO file | 5 min |
| Add `checkCodeStatus()` to auth service | 15 min |
| Add result caching line to `verifyCode()` | 5 min |
| Add controller endpoint | 10 min |
| Unit tests (4-5 cases) | 30-45 min |
| Integration testing | 15 min |
| **Total** | **~1.5 hours** |

---

## Rollout

- **Backward compatible**: No breaking changes to existing `requestCode`/`verifyCode` flow
- **No migration needed**: New Redis key `code-result:{id}` is ephemeral (30s TTL)
- **No env vars needed**: Pure logic change, no new infrastructure
- **Deploy order**: Backend first → then Roku app can use the endpoint
