Commit e9a39b6f authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

API éditeur de kits : persistance en base + CRUD complet

- Modèles Kit/KitCharacter/KitChannel/KitDocument/KitInject (cloisonnés par
  organisation) + migration kits_editable.
- Import idempotent des kits fournis dans chaque organisation ; liste et
  installation branchées sur la base.
- CRUD : créer/dupliquer/modifier/supprimer/réinitialiser un kit ; CRUD des
  injects et personnages.

Co-Authored-By: Claude (RCA)
parent 7ee67ed9
Loading
Loading
Loading
Loading
+78 −0
Original line number Diff line number Diff line
-- Kits editables persistes en base (propres a chaque organisation).
CREATE TABLE "Kit" (
  "id" TEXT NOT NULL,
  "tenantId" TEXT NOT NULL,
  "builtinId" TEXT,
  "name" TEXT NOT NULL,
  "sector" TEXT NOT NULL,
  "source" TEXT NOT NULL,
  "description" TEXT NOT NULL,
  "objectives" TEXT,
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,
  CONSTRAINT "Kit_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Kit_tenantId_idx" ON "Kit"("tenantId");
ALTER TABLE "Kit" ADD CONSTRAINT "Kit_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

CREATE TABLE "KitCharacter" (
  "id" TEXT NOT NULL,
  "kitId" TEXT NOT NULL,
  "ref" TEXT NOT NULL,
  "name" TEXT NOT NULL,
  "title" TEXT,
  "orgUnit" TEXT,
  "kind" TEXT NOT NULL DEFAULT 'PLAYER',
  "simEmail" TEXT,
  "avatarColor" TEXT,
  CONSTRAINT "KitCharacter_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "KitCharacter_kitId_idx" ON "KitCharacter"("kitId");
ALTER TABLE "KitCharacter" ADD CONSTRAINT "KitCharacter_kitId_fkey" FOREIGN KEY ("kitId") REFERENCES "Kit"("id") ON DELETE CASCADE ON UPDATE CASCADE;

CREATE TABLE "KitChannel" (
  "id" TEXT NOT NULL,
  "kitId" TEXT NOT NULL,
  "ref" TEXT NOT NULL,
  "name" TEXT NOT NULL,
  CONSTRAINT "KitChannel_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "KitChannel_kitId_idx" ON "KitChannel"("kitId");
ALTER TABLE "KitChannel" ADD CONSTRAINT "KitChannel_kitId_fkey" FOREIGN KEY ("kitId") REFERENCES "Kit"("id") ON DELETE CASCADE ON UPDATE CASCADE;

CREATE TABLE "KitDocument" (
  "id" TEXT NOT NULL,
  "kitId" TEXT NOT NULL,
  "ref" TEXT,
  "folder" TEXT,
  "name" TEXT NOT NULL,
  "description" TEXT,
  "body" TEXT,
  "file" TEXT,
  "mimeType" TEXT,
  CONSTRAINT "KitDocument_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "KitDocument_kitId_idx" ON "KitDocument"("kitId");
ALTER TABLE "KitDocument" ADD CONSTRAINT "KitDocument_kitId_fkey" FOREIGN KEY ("kitId") REFERENCES "Kit"("id") ON DELETE CASCADE ON UPDATE CASCADE;

CREATE TABLE "KitInject" (
  "id" TEXT NOT NULL,
  "kitId" TEXT NOT NULL,
  "order" INTEGER NOT NULL DEFAULT 0,
  "offsetMinutes" INTEGER NOT NULL,
  "channel" TEXT NOT NULL,
  "title" TEXT NOT NULL,
  "body" TEXT NOT NULL,
  "senderRef" TEXT,
  "subject" TEXT,
  "targetRefs" TEXT[],
  "attachmentRefs" TEXT[],
  "channelRef" TEXT,
  "network" TEXT,
  "authorName" TEXT,
  "authorHandle" TEXT,
  "likes" INTEGER,
  CONSTRAINT "KitInject_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "KitInject_kitId_idx" ON "KitInject"("kitId");
ALTER TABLE "KitInject" ADD CONSTRAINT "KitInject_kitId_fkey" FOREIGN KEY ("kitId") REFERENCES "Kit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+86 −0
Original line number Diff line number Diff line
@@ -34,6 +34,7 @@ model Tenant {
  documents         Document[]
  observerNotes     ObserverNote[]
  events            EventLog[]
  kits              Kit[]
  createdAt         DateTime           @default(now())
  updatedAt         DateTime           @updatedAt
}
@@ -494,3 +495,88 @@ model EventLog {
  @@index([tenantId])
  @@index([exerciseId, atWall])
}

/// Kit d'exercice editable (propre a chaque organisation). Importe depuis les kits
/// fournis (builtinId) puis modifiable ; sert de modele a l'installation d'exercices.
model Kit {
  id          String   @id @default(cuid())
  tenantId    String
  tenant      Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  /// Id du kit fourni d'origine (pour reimport/reset) ; null si kit cree de zero.
  builtinId   String?
  name        String
  sector      String
  source      String
  description  String
  objectives   String?
  characters  KitCharacter[]
  channels    KitChannel[]
  documents   KitDocument[]
  injects     KitInject[]
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@index([tenantId])
}

model KitCharacter {
  id          String  @id @default(cuid())
  kitId       String
  kit         Kit     @relation(fields: [kitId], references: [id], onDelete: Cascade)
  ref         String
  name        String
  title       String?
  orgUnit     String?
  kind        String  @default("PLAYER")
  simEmail    String?
  avatarColor String?

  @@index([kitId])
}

model KitChannel {
  id    String @id @default(cuid())
  kitId String
  kit   Kit    @relation(fields: [kitId], references: [id], onDelete: Cascade)
  ref   String
  name  String

  @@index([kitId])
}

model KitDocument {
  id          String  @id @default(cuid())
  kitId       String
  kit         Kit     @relation(fields: [kitId], references: [id], onDelete: Cascade)
  ref         String?
  folder      String?
  name        String
  description String?
  body        String?
  file        String?
  mimeType    String?

  @@index([kitId])
}

model KitInject {
  id             String   @id @default(cuid())
  kitId          String
  kit            Kit      @relation(fields: [kitId], references: [id], onDelete: Cascade)
  order          Int      @default(0)
  offsetMinutes  Int
  channel        String
  title          String
  body           String
  senderRef      String?
  subject        String?
  targetRefs     String[]
  attachmentRefs String[]
  channelRef     String?
  network        String?
  authorName     String?
  authorHandle   String?
  likes          Int?

  @@index([kitId])
}
+140 −0
Original line number Diff line number Diff line
import {
  IsArray,
  IsIn,
  IsInt,
  IsOptional,
  IsString,
  MaxLength,
  Min,
  MinLength,
} from 'class-validator';

export class CreateKitDto {
  @IsString()
  @MinLength(1)
  @MaxLength(200)
  name!: string;

  @IsOptional()
  @IsString()
  @MaxLength(120)
  sector?: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsString()
  objectives?: string;
}

export class UpdateKitDto {
  @IsOptional()
  @IsString()
  @MinLength(1)
  @MaxLength(200)
  name?: string;

  @IsOptional()
  @IsString()
  @MaxLength(120)
  sector?: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsString()
  objectives?: string;
}

export class KitInjectDto {
  @IsInt()
  @Min(0)
  offsetMinutes!: number;

  @IsIn(['EMAIL', 'CHAT', 'SOCIAL', 'NEWS', 'CALL'])
  channel!: 'EMAIL' | 'CHAT' | 'SOCIAL' | 'NEWS' | 'CALL';

  @IsString()
  @MinLength(1)
  @MaxLength(240)
  title!: string;

  @IsString()
  body!: string;

  @IsOptional()
  @IsString()
  senderRef?: string;

  @IsOptional()
  @IsString()
  @MaxLength(200)
  subject?: string;

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  targetRefs?: string[];

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  attachmentRefs?: string[];

  @IsOptional()
  @IsString()
  channelRef?: string;

  @IsOptional()
  @IsIn(['X', 'LINKEDIN', 'INSTAGRAM'])
  network?: 'X' | 'LINKEDIN' | 'INSTAGRAM';

  @IsOptional()
  @IsString()
  @MaxLength(120)
  authorName?: string;

  @IsOptional()
  @IsString()
  @MaxLength(60)
  authorHandle?: string;

  @IsOptional()
  @IsInt()
  @Min(0)
  likes?: number;
}

export class KitCharacterDto {
  @IsString()
  @MinLength(1)
  @MaxLength(160)
  name!: string;

  @IsOptional()
  @IsString()
  @MaxLength(160)
  title?: string;

  @IsOptional()
  @IsString()
  @MaxLength(160)
  orgUnit?: string;

  @IsIn(['PLAYER', 'NPC'])
  kind!: 'PLAYER' | 'NPC';

  @IsOptional()
  @IsString()
  @MaxLength(200)
  simEmail?: string;

  @IsOptional()
  @IsString()
  @MaxLength(20)
  avatarColor?: string;
}
+104 −4
Original line number Diff line number Diff line
import { Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  Patch,
  Post,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthUser } from '../auth/auth.types';
import { KitsService } from './kits.service';
import { CreateKitDto, KitCharacterDto, KitInjectDto, UpdateKitDto } from './dto/kit.dto';

const EDITORS = ['TENANT_ADMIN', 'DESIGNER', 'ANIMATOR'] as const;

@Controller('kits')
@UseGuards(AuthGuard, RolesGuard)
@@ -12,14 +26,100 @@ export class KitsController {
  constructor(private readonly kits: KitsService) {}

  @Get()
  list() {
    return this.kits.list();
  list(@CurrentUser() me: AuthUser) {
    return this.kits.list(me.tenantId);
  }

  @Get(':id')
  get(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.kits.get(me.tenantId, id);
  }

  @Post(':id/install')
  @Roles('TENANT_ADMIN', 'DESIGNER', 'ANIMATOR')
  @Roles(...EDITORS)
  @HttpCode(HttpStatus.CREATED)
  install(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.kits.install(me.tenantId, id);
  }

  // ---- Edition (Phase 2) ----

  @Post()
  @Roles(...EDITORS)
  create(@CurrentUser() me: AuthUser, @Body() dto: CreateKitDto) {
    return this.kits.createKit(me.tenantId, dto);
  }

  @Post(':id/duplicate')
  @Roles(...EDITORS)
  duplicate(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.kits.duplicateKit(me.tenantId, id);
  }

  @Patch(':id')
  @Roles(...EDITORS)
  update(@CurrentUser() me: AuthUser, @Param('id') id: string, @Body() dto: UpdateKitDto) {
    return this.kits.updateKit(me.tenantId, id, dto);
  }

  @Delete(':id')
  @Roles(...EDITORS)
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.kits.deleteKit(me.tenantId, id);
  }

  @Post(':id/reset')
  @Roles(...EDITORS)
  reset(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    return this.kits.resetKit(me.tenantId, id);
  }

  @Post(':id/injects')
  @Roles(...EDITORS)
  addInject(@CurrentUser() me: AuthUser, @Param('id') id: string, @Body() dto: KitInjectDto) {
    return this.kits.addInject(me.tenantId, id, dto);
  }

  @Patch(':id/injects/:injectId')
  @Roles(...EDITORS)
  updateInject(
    @CurrentUser() me: AuthUser,
    @Param('id') id: string,
    @Param('injectId') injectId: string,
    @Body() dto: KitInjectDto,
  ) {
    return this.kits.updateInject(me.tenantId, id, injectId, dto);
  }

  @Delete(':id/injects/:injectId')
  @Roles(...EDITORS)
  @HttpCode(HttpStatus.NO_CONTENT)
  removeInject(@CurrentUser() me: AuthUser, @Param('id') id: string, @Param('injectId') injectId: string) {
    return this.kits.deleteInject(me.tenantId, id, injectId);
  }

  @Post(':id/characters')
  @Roles(...EDITORS)
  addCharacter(@CurrentUser() me: AuthUser, @Param('id') id: string, @Body() dto: KitCharacterDto) {
    return this.kits.addCharacter(me.tenantId, id, dto);
  }

  @Patch(':id/characters/:charId')
  @Roles(...EDITORS)
  updateCharacter(
    @CurrentUser() me: AuthUser,
    @Param('id') id: string,
    @Param('charId') charId: string,
    @Body() dto: KitCharacterDto,
  ) {
    return this.kits.updateCharacter(me.tenantId, id, charId, dto);
  }

  @Delete(':id/characters/:charId')
  @Roles(...EDITORS)
  @HttpCode(HttpStatus.NO_CONTENT)
  removeCharacter(@CurrentUser() me: AuthUser, @Param('id') id: string, @Param('charId') charId: string) {
    return this.kits.deleteCharacter(me.tenantId, id, charId);
  }
}
+358 −24

File changed.

Preview size limit exceeded, changes collapsed.

Loading