# Implementation Spec: Archive Registration

## Overview

A bulk admin operation that moves registrations into a terminal `ARCHIVED` state, freeing their seats and preventing further status transitions.

| Feature | Endpoint | Status change |
| --- | --- | --- |
| Archive Registration | `PUT /registration/archive` | `pending / waitlisted / rejected / on_hold / cancelled / save_as_draft` → `archived` |

Follows the **Controller → RegistrationActionService → Repository** pattern. Accepts a single ID or an array (up to 50). Returns `{ processed: number[], skipped: number[] }` so the caller knows which IDs were archived and which were skipped.

---

## Rules & Conventions

### Code Organisation

| What | Where |
| --- | --- |
| TypeScript interfaces | `src/common/interfaces/registration-action.interface.ts` |
| Action-scoped constants (bulk limit, allowed statuses, allowed roles, blocked payment statuses) | `src/registration/registration-action.constants.ts` |
| Error codes | `src/common/constants/error-string-constants.ts` |
| Error messages | `src/common/i18n/error-messages.ts` |
| Enums (action key) | `src/common/enum/registration-action-key.enum.ts` |

### Logging

- Use the injected `AppLoggerService` (`this.logger`) — **no `console.log`**.
- Log format: `[RegistrationAction:<actionKey>] <message>` so logs are filterable by action.
- Log **skip reasons** at `log` level; log **errors** at `error` level with the stack.

```typescript
// ✅ correct
this.logger.log(`[RegistrationAction:archive] Skipped reg ${id}: not in archivable status`);
this.logger.error(`[RegistrationAction:archive] Failed for reg ${id}: ${err.message}`, err.stack);

// ❌ never
console.log(...);
```

### Error Codes

Every error must map to a code in `error-string-constants.ts` and a human message in `error-messages.ts`. No inline strings.

### Field Selection

- Repository queries must select **only the fields actually used**. Use `QueryBuilder` with explicit `.select()` — never `find()` for action queries.

### Service / Repository Boundary

- **Business logic** (status checks, role checks, payment checks, allocation clearing) lives in `RegistrationActionService`.
- **DB operations** (update, select, seat count adjustment) live in `RegistrationRepository`.
- The service never calls `getRepository()` directly.

---

## Architecture

```text
Controller
  └── PUT /registration/archive        → RegistrationActionService.archive(dto, user)
                                               ↓
                           registrationRepository.findRegistrationsByIdsForAction(ids)
                                               ↓  (per registration, in parallel)
                           validateForAction(registration, ARCHIVE, user)
                                   ├── skip  → push to skipped[]
                                   └── valid → (if allocatedProgramId exists)
                                               allocationClearingService.clearRegistrationAllocations()
                                               ↓
                                               registrationRepository.archiveRegistration(id, userId)
                                                 ├── transaction: adjustSeatCountsOnCancel()
                                                 ├── null allocatedProgram + allocatedSession
                                                 └── set status → ARCHIVED
                                               push to processed[]
```

---

## Prerequisites (already implemented)

### Enum: `RegistrationActionKey`

**File:** `src/common/enum/registration-action-key.enum.ts`

```typescript
export enum RegistrationActionKey {
  ARCHIVE = 'archive',
}
```

### Enum: `RegistrationStatusEnum`

**File:** `src/common/enum/registration-status.enum.ts`

```typescript
export enum RegistrationStatusEnum {
  DRAFT = 'draft',
  SAVE_AS_DRAFT = 'save_as_draft',
  ATTEMPTED = 'attempted',
  PENDING_APPROVAL = 'pending_approval',
  PENDING = 'pending',
  WAITLISTED = 'waitlisted',
  COMPLETED = 'completed',
  CANCELLED = 'cancelled',
  REJECTED = 'rejected',
  ON_HOLD = 'on_hold',
  ARCHIVED = 'archived',
}
```

> **Dashboard impact:** `ARCHIVED` must be excluded from all seat count, KPI, and list queries. See [Impact on Dashboards & Reports](#impact-on-dashboards--reports).

### Error Codes

**File:** `src/common/constants/error-string-constants.ts`

```typescript
// Archive
REGISTRATION_ARCHIVE_FAILED: 'RE_ARCHIVE_FAILED',
REGISTRATION_ALREADY_ARCHIVED: 'RE_ALREADY_ARCHIVED',
REGISTRATION_ARCHIVE_INVALID_STATUS: 'RE_ARCHIVE_INVALID_STATUS',
REGISTRATION_ARCHIVE_UNAUTHORIZED: 'RE_ARCHIVE_UNAUTHORIZED',
REGISTRATION_ARCHIVE_HAS_COMPLETED_PAYMENT: 'RE_ARCHIVE_HAS_PAYMENT',
PROGRAM_REGISTRATION_NOTFOUND: 'PR_NF_001',   // already exists
```

**File:** `src/common/i18n/error-messages.ts`

```typescript
RE_ARCHIVE_FAILED: 'Failed to archive registration with ID {0}',
RE_ALREADY_ARCHIVED: 'Registration with ID {0} is already archived',
RE_ARCHIVE_INVALID_STATUS: 'Registration {0} cannot be archived from status {1}',
RE_ARCHIVE_UNAUTHORIZED: 'User not authorized to archive registration',
RE_ARCHIVE_HAS_PAYMENT: 'Registration {0} has a completed payment and cannot be archived',
```

---

## Step 1 — Interfaces

**File:** `src/common/interfaces/registration-action.interface.ts`

```typescript
import { RegistrationStatusEnum } from 'src/common/enum/registration-status.enum';
import { PaymentStatusEnum } from 'src/common/enum/payment-status.enum';

export interface RegistrationActionValidation {
  valid: boolean;
  reason?: string;
  errorCode?: string;
}

export interface RegistrationActionRow {
  id: number;
  registrationStatus: RegistrationStatusEnum;
  allocatedProgramId: number | null;
  paymentStatus: PaymentStatusEnum | null;
}
```

---

## Step 2 — Constants

**File:** `src/registration/registration-action.constants.ts`

```typescript
import { RegistrationActionKey } from 'src/common/enum/registration-action-key.enum';
import { RegistrationStatusEnum } from 'src/common/enum/registration-status.enum';
import { PaymentStatusEnum } from 'src/common/enum/payment-status.enum';
import { ROLE_KEYS } from 'src/common/constants/strings-constants';

export const ARCHIVE_BULK_LIMIT = 50;

export const ARCHIVABLE_STATUSES: Record<RegistrationActionKey, RegistrationStatusEnum[]> = {
  [RegistrationActionKey.ARCHIVE]: [
    RegistrationStatusEnum.PENDING,
    RegistrationStatusEnum.WAITLISTED,
    RegistrationStatusEnum.REJECTED,
    RegistrationStatusEnum.ON_HOLD,
    RegistrationStatusEnum.CANCELLED,
    RegistrationStatusEnum.SAVE_AS_DRAFT,
  ],
};

export const ARCHIVE_ALLOWED_ROLES: string[] = [
  ROLE_KEYS.ADMIN,
  ROLE_KEYS.OPERATIONAL_MANAGER,
];

export const ARCHIVE_BLOCKED_PAYMENT_STATUSES: PaymentStatusEnum[] = [
  PaymentStatusEnum.ONLINE_COMPLETED,
  PaymentStatusEnum.OFFLINE_COMPLETED,
];
```

> `COMPLETED` registrations are not archivable — they represent a confirmed, financially settled state.
> Registrations with `ONLINE_COMPLETED` or `OFFLINE_COMPLETED` payment are blocked even if the registration status is otherwise archivable.

---

## Step 3 — DTO

**File:** `src/registration/dto/archive-registration.dto.ts`

Accepts a **single integer or an array** — the `@Transform` normalises both shapes before validation runs.

```typescript
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsInt, ArrayMinSize, ArrayMaxSize } from 'class-validator';
import { Transform } from 'class-transformer';

export class ArchiveRegistrationDto {
  @ApiProperty({
    description: 'Registration IDs to archive. Accepts a single ID or an array. Max 50 per request.',
    oneOf: [
      { type: 'integer', example: 101 },
      { type: 'array', items: { type: 'integer' }, example: [101, 102, 103] },
    ],
  })
  @Transform(({ value }) => {
    const arr = Array.isArray(value) ? value : [value];
    return arr.map(Number);
  })
  @IsArray()
  @ArrayMinSize(1)
  @ArrayMaxSize(50)
  @IsInt({ each: true })
  registrationIds!: number[];
}
```

**Input normalisation:**

| Sent by UI | After `@Transform` | Outcome |
| --- | --- | --- |
| `123` | `[123]` | ✅ valid |
| `"123"` | `[123]` | ✅ coerced to int |
| `[101, 102]` | `[101, 102]` | ✅ valid |
| `[]` | `[]` | ❌ fails `ArrayMinSize(1)` |
| 51+ items | 51+ items | ❌ fails `ArrayMaxSize(50)` |

---

## Step 4 — Repository Methods

**File:** `src/registration/registration.repository.ts`

### `findRegistrationsByIdsForAction`

Bulk lookup. Fetches only the four fields the service needs. Joins `paymentDetails` to get payment status — uses `QueryBuilder`, never `find()`.

```typescript
async findRegistrationsByIdsForAction(ids: number[]): Promise<RegistrationActionRow[]> {
  const rows = await this.registrationRepo
    .createQueryBuilder('reg')
    .select(['reg.id', 'reg.registrationStatus', 'reg.allocatedProgramId'])
    .leftJoin('reg.paymentDetails', 'payment')
    .addSelect(['payment.paymentStatus'])
    .where('reg.id IN (:...ids)', { ids })
    .andWhere('reg.deletedAt IS NULL')
    .getMany();

  return rows.map((row) => ({
    id: row.id,
    registrationStatus: row.registrationStatus,
    allocatedProgramId: row.allocatedProgramId ?? null,
    paymentStatus: row.paymentDetails?.[0]?.paymentStatus ?? null,
  }));
}
```

### `archiveRegistration`

Runs inside a transaction. Order matters:

1. Re-fetch registration with `allocatedProgram` + `program` relations (needed for seat count adjustment).
2. Decrement seat counts if registration had an allocated program (`adjustSeatCountsOnCancel`).
3. Update status to `ARCHIVED`, null out `allocatedProgram` and `allocatedSession`.

```typescript
async archiveRegistration(
  registrationId: number,
  archivedBy: number,
): Promise<{ registrationId: number; archived: boolean }> {
  try {
    await this.dataSource.manager.transaction(async (manager) => {
      const registration = await manager.findOne(ProgramRegistration, {
        where: { id: registrationId, deletedAt: IsNull() },
        relations: ['allocatedProgram', 'program'],
      });

      if (!registration) {
        throw new InifniNotFoundException(
          ERROR_CODES.PROGRAM_REGISTRATION_NOTFOUND,
          null,
          null,
          registrationId.toString(),
        );
      }

      await this.adjustSeatCountsOnCancel(manager, registration);

      await manager.update(ProgramRegistration, registrationId, {
        registrationStatus: RegistrationStatusEnum.ARCHIVED,
        updatedBy: { id: archivedBy } as any,
        allocatedProgram: null,
        allocatedSession: null,
        auditRefId: registrationId,
        parentRefId: registrationId,
      });
    });

    return { registrationId, archived: true };
  } catch (error) {
    this.logger.error('Registration archive failed', (error as Error)?.stack);
    handleKnownErrors(ERROR_CODES.REGISTRATION_ARCHIVE_FAILED, error);
  }
}
```

> `adjustSeatCountsOnCancel` is a private method already used by the cancel flow — no duplication.

---

## Step 5 — Service

**File:** `src/registration/registration-action.service.ts`

```typescript
import { Injectable } from '@nestjs/common';
import { RegistrationRepository } from './registration.repository';
import { ArchiveRegistrationDto } from './dto/archive-registration.dto';
import { ARCHIVABLE_STATUSES, ARCHIVE_ALLOWED_ROLES, ARCHIVE_BLOCKED_PAYMENT_STATUSES } from './registration-action.constants';
import { RegistrationActionKey } from 'src/common/enum/registration-action-key.enum';
import { RegistrationStatusEnum } from 'src/common/enum/registration-status.enum';
import { RegistrationActionValidation, RegistrationActionRow } from 'src/common/interfaces/registration-action.interface';
import { AllocationClearingService } from 'src/common/services/allocation-clearing.service';
import { AppLoggerService } from 'src/common/services/logger.service';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { ERROR_MESSAGES } from 'src/common/i18n/error-messages';
import { CLEARANCE_REASONS } from 'src/common/constants/strings-constants';
import { User } from 'src/common/entities';

@Injectable()
export class RegistrationActionService {
  constructor(
    private readonly registrationRepository: RegistrationRepository,
    private readonly allocationClearingService: AllocationClearingService,
    private readonly logger: AppLoggerService,
  ) {}

  async archive(
    dto: ArchiveRegistrationDto,
    user: User,
  ): Promise<{ processed: number[]; skipped: number[] }> {
    const processed: number[] = [];
    const skipped: number[] = [];

    const registrations = await this.registrationRepository.findRegistrationsByIdsForAction(dto.registrationIds);
    const registrationMap = new Map(registrations.map((r) => [r.id, r]));

    await Promise.all(
      dto.registrationIds.map(async (id) => {
        const registration = registrationMap.get(id) ?? null;
        const validation = this.validateForAction(registration, RegistrationActionKey.ARCHIVE, user);

        if (!validation.valid) {
          this.logger.log(
            `[RegistrationAction:${RegistrationActionKey.ARCHIVE}] Skipped reg ${id}: ${validation.reason}`,
          );
          skipped.push(id);
          return;
        }

        try {
          if (registration!.allocatedProgramId) {
            await this.allocationClearingService.clearRegistrationAllocations(
              registration!.id,
              user.id,
              CLEARANCE_REASONS.REGISTRATION_CANCELLED(registration!.id),
            );
          }

          await this.registrationRepository.archiveRegistration(id, user.id);
          processed.push(id);
        } catch (error) {
          this.logger.error(
            `[RegistrationAction:${RegistrationActionKey.ARCHIVE}] Failed for reg ${id}: ${(error as Error)?.message}`,
            (error as Error)?.stack,
          );
          skipped.push(id);
        }
      }),
    );

    return { processed, skipped };
  }

  private validateForAction(
    registration: RegistrationActionRow | null,
    actionKey: RegistrationActionKey,
    user: User,
  ): RegistrationActionValidation {
    if (!registration) {
      return this.invalid(ERROR_CODES.PROGRAM_REGISTRATION_NOTFOUND);
    }

    switch (actionKey) {
      case RegistrationActionKey.ARCHIVE: {
        const hasPermission = user.userRoleMaps.some((urm) =>
          ARCHIVE_ALLOWED_ROLES.includes(urm.role.roleKey),
        );
        if (!hasPermission) {
          return this.invalid(ERROR_CODES.REGISTRATION_ARCHIVE_UNAUTHORIZED);
        }

        if (registration.registrationStatus === RegistrationStatusEnum.ARCHIVED) {
          return this.invalid(ERROR_CODES.REGISTRATION_ALREADY_ARCHIVED);
        }

        if (!ARCHIVABLE_STATUSES[actionKey].includes(registration.registrationStatus)) {
          return this.invalid(ERROR_CODES.REGISTRATION_ARCHIVE_INVALID_STATUS);
        }

        if (registration.paymentStatus && ARCHIVE_BLOCKED_PAYMENT_STATUSES.includes(registration.paymentStatus)) {
          return this.invalid(ERROR_CODES.REGISTRATION_ARCHIVE_HAS_COMPLETED_PAYMENT);
        }
        break;
      }
    }

    return { valid: true };
  }

  private invalid(errorCode: string): RegistrationActionValidation {
    return { valid: false, reason: ERROR_MESSAGES[errorCode], errorCode };
  }
}
```

**Validation order inside `ARCHIVE` case (per registration):**

1. **Not found** — ID not in DB or soft-deleted → skipped
2. **Role check** — caller must be `admin` or `operational_manager` → skipped
3. **Already archived** — idempotency guard → skipped
4. **Status check** — must be in `ARCHIVABLE_STATUSES[ARCHIVE]` → skipped
5. **Payment check** — `paymentStatus` must not be in `ARCHIVE_BLOCKED_PAYMENT_STATUSES` → skipped

If allocation clearing or DB update throws, that registration is caught individually and pushed to `skipped` — one failure does not abort the rest of the batch.

---

## Step 6 — Controller

**File:** `src/registration/registration.controller.ts`

```typescript
@Put('archive')
@HttpCode(HttpStatus.OK)
@Roles(ROLE_VALUES.ADMIN, ROLE_VALUES.OPERATIONAL_MANAGER)
@ApiOperation({ summary: 'Bulk archive registrations (admin/operational_manager only)' })
@ApiBody({ type: ArchiveRegistrationDto })
@ApiResponse({ status: HttpStatus.OK, description: SWAGGER_API_RESPONSE.UPDATED })
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: SWAGGER_API_RESPONSE.BAD_REQUEST })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: SWAGGER_API_RESPONSE.NOT_FOUND })
@ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, description: SWAGGER_API_RESPONSE.INTERNAL_SERVER_ERROR })
async archiveRegistration(
  @Body(new ValidationPipe({ transform: true, whitelist: true })) dto: ArchiveRegistrationDto,
  @Req() req: any,
  @Res() res: Response,
) {
  const user = req.user;
  if (!user || !user.id) {
    throw new InifniNotFoundException(ERROR_CODES.USER_NOTFOUND, null, null, 'User not found in request');
  }
  this.logger.log('Archive request received', dto);

  try {
    const data = await this.registrationActionService.archive(dto, user);
    await this.responseService.success(res, 'Archive processed successfully', data);
  } catch (error) {
    handleControllerError(res, error);
  }
}
```

---

## API

| Method | Endpoint | Roles | Content-Type |
| --- | --- | --- | --- |
| `PUT` | `/registration/archive` | `admin`, `operational_manager` | `application/json` |

### Request — single ID

```http
PUT /registration/archive
Authorization: Bearer <firebase-token>
Content-Type: application/json

{
  "registrationIds": 101
}
```

### Request — array of IDs

```http
PUT /registration/archive
Authorization: Bearer <firebase-token>
Content-Type: application/json

{
  "registrationIds": [101, 102, 103]
}
```

### Success Response — `200 OK`

```json
{
  "success": true,
  "data": {
    "processed": [101, 102],
    "skipped": [103]
  },
  "error": null
}
```

`processed` — IDs successfully archived.  
`skipped` — IDs that failed validation or had a DB error. Surface these to the user.

### Per-registration skip reasons (appear in `skipped[]`)

| Error Code | Reason |
| --- | --- |
| `PR_NF_001` | Registration ID does not exist or is soft-deleted |
| `RE_ARCHIVE_UNAUTHORIZED` | Caller's role is not `admin` or `operational_manager` |
| `RE_ALREADY_ARCHIVED` | Registration is already `ARCHIVED` |
| `RE_ARCHIVE_INVALID_STATUS` | Status is not archivable (e.g. `COMPLETED`) |
| `RE_ARCHIVE_HAS_PAYMENT` | Registration has `ONLINE_COMPLETED` or `OFFLINE_COMPLETED` payment — handle refund first |

### Request-level Error Responses

The entire call fails before any processing:

| HTTP Status | When |
| --- | --- |
| `400 Bad Request` | `registrationIds` missing, not a number/array, or exceeds 50 items |
| `401 Unauthorized` | Missing or invalid Firebase token |
| `500 Internal Server Error` | Unexpected server error |

---

## How Admin Actions Are Tracked

No extra code needed — `ProgramRegistration` already has `@Auditable()`. On every `afterUpdate`, `AuditHistorySubscriber` writes a row to `audit_history_log`:

| Column | What it stores |
| --- | --- |
| `entity_type` | `hdb_program_registration` |
| `entity_id` | Registration ID |
| `action` | `UPDATE` |
| `user_id` | Admin's user ID |
| `api_endpoint` | `/registration/archive` |
| `metadata` | JSON diff — e.g. `{ registrationStatus: { old: 'pending', new: 'archived' } }` |

Every `repo.update()` also stamps `updatedBy: { id: archivedBy }` on the registration row.

---

## Impact on Dashboards & Reports

### Queries that need updating

In `src/registration/registration.repository.ts`, add `RegistrationStatusEnum.ARCHIVED` to every `excludeStatuses` / `NOT IN` array:

```typescript
// BEFORE
excludeStatuses: [RegistrationStatusEnum.SAVE_AS_DRAFT, RegistrationStatusEnum.CANCELLED]

// AFTER
excludeStatuses: [RegistrationStatusEnum.SAVE_AS_DRAFT, RegistrationStatusEnum.CANCELLED, RegistrationStatusEnum.ARCHIVED]
```

Archived registrations free up their seat — `areSeatsFilled()` and seat count queries must exclude `ARCHIVED`.

Affected locations in `registration.repository.ts`:

| Query | Currently excludes |
| --- | --- |
| Swap request (line ~203) | `SAVE_AS_DRAFT`, `CANCELLED`, `REJECTED` |
| Age/gender breakdown | `SAVE_AS_DRAFT`, `CANCELLED` |
| KPI/seat count | `SAVE_AS_DRAFT`, `CANCELLED`, `REJECTED` |
| Registration list views | `SAVE_AS_DRAFT`, `CANCELLED` |

### Where `ARCHIVED` should be visible

Only in two places:
1. Admin list view — when explicitly filtering by `registrationStatus=archived`
2. Archive history report — via `audit_history_log`

---

## Open Question

`DRAFT`, `ATTEMPTED`, and `PENDING_APPROVAL` exist in `RegistrationStatusEnum` but are **not** in `ARCHIVABLE_STATUSES`. Confirm whether these should be archivable and add them to the array in [registration-action.constants.ts](../src/registration/registration-action.constants.ts) if so.

---

## Files Touched

| File | Change |
| --- | --- |
| `src/common/enum/registration-action-key.enum.ts` | `ARCHIVE` value |
| `src/common/enum/registration-status.enum.ts` | `ARCHIVED` value |
| `src/common/constants/error-string-constants.ts` | 5 archive error codes |
| `src/common/i18n/error-messages.ts` | 5 archive error messages |
| `src/common/interfaces/registration-action.interface.ts` | `RegistrationActionValidation`, `RegistrationActionRow` (with `paymentStatus`) |
| `src/registration/dto/archive-registration.dto.ts` | `ArchiveRegistrationDto` — single int or array, max 50 |
| `src/registration/registration-action.constants.ts` | `ARCHIVE_BULK_LIMIT`, `ARCHIVABLE_STATUSES`, `ARCHIVE_ALLOWED_ROLES`, `ARCHIVE_BLOCKED_PAYMENT_STATUSES` |
| `src/registration/registration-action.service.ts` | `RegistrationActionService` with bulk `archive()` + 5-step validation |
| `src/registration/registration.repository.ts` | `findRegistrationsByIdsForAction()`, `archiveRegistration()` (transactional, seat count, null allocations) + update `excludeStatuses` arrays |
| `src/registration/registration.controller.ts` | `PUT /archive` endpoint |

---

## Implementation Order

```
1.  Add ARCHIVED to RegistrationStatusEnum               (no dependencies)
2.  Add 5 error codes to error-string-constants.ts       (no dependencies)
3.  Add 5 error messages to error-messages.ts            (no dependencies)
4.  Add ARCHIVE to RegistrationActionKey enum            (no dependencies)
5.  Create registration-action.interface.ts              (depends on enums)
6.  Create ArchiveRegistrationDto                        (no dependencies)
7.  Create registration-action.constants.ts              (depends on enums)
8.  Add findRegistrationsByIdsForAction() to repo        (depends on interface)
9.  Add archiveRegistration() to repo                    (depends on enum)
10. Implement RegistrationActionService (bulk pattern)   (depends on repo + constants)
11. Wire controller endpoint                             (depends on service)
12. Fix excludeStatuses arrays in repo                   (do alongside step 1)
```
