Commit 756c0271 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Injects : planifier un article de presse + compteur T+ en heures/minutes

- Nouveau canal d'inject NEWS : planifiable (T+) ou à la volée ; champs média
  (Le Monde, Le Figaro, franceinfo, Ouest-France, générique), source, rubrique,
  titre, corps. Publie l'article via NewsService au déclenchement.
- Instagram ajouté au sélecteur des injects réseau social.
- Compteur de planification en heures + minutes (au lieu de minutes seules),
  adapté à une journée d'exercice.

Co-Authored-By: Claude (RCA)
parent 71c38ab0
Loading
Loading
Loading
Loading
+22 −2
Original line number Diff line number Diff line
@@ -16,8 +16,8 @@ export class CreateInjectDto {
  title!: string;

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

  @IsIn(['SCHEDULED', 'MANUAL'])
  trigger!: 'SCHEDULED' | 'MANUAL';
@@ -98,4 +98,24 @@ export class CreateInjectDto {
  @IsInt()
  @Min(0)
  comments?: number;

  // --- Presse ---
  @IsOptional()
  @IsIn(['GENERIC', 'LEMONDE', 'FIGARO', 'FRANCEINFO', 'OUESTFRANCE'])
  outlet?: 'GENERIC' | 'LEMONDE' | 'FIGARO' | 'FRANCEINFO' | 'OUESTFRANCE';

  @IsOptional()
  @IsString()
  @MaxLength(240)
  headline?: string;

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

  @IsOptional()
  @IsString()
  @MaxLength(80)
  newsCategory?: string;
}
+9 −0
Original line number Diff line number Diff line
@@ -40,6 +40,15 @@ export interface SocialInjectPayload {
  comments?: number;
}

/** Contenu d'un inject presse (article publie au declenchement). */
export interface NewsInjectPayload {
  outlet: 'GENERIC' | 'LEMONDE' | 'FIGARO' | 'FRANCEINFO' | 'OUESTFRANCE';
  headline: string;
  source: string;
  category?: string;
  body: string;
}

/** Rooms Socket.IO. */
export const exerciseRoom = (exerciseId: string): string => `ex:${exerciseId}`;
export const participantRoom = (exerciseId: string, participantId: string): string =>
+34 −1
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { RealtimeGateway } from './realtime.gateway';
import { ChatService } from './chat.service';
import { SocialService } from './social.service';
import { NewsService } from './news.service';
import { computeElapsedSeconds } from './clock';
import {
  RT,
@@ -15,6 +16,7 @@ import {
  type ChatInjectPayload,
  type EmailPayload,
  type InjectTargeting,
  type NewsInjectPayload,
  type SocialInjectPayload,
} from './engine.types';
import type { CreateInjectDto } from './dto/create-inject.dto';
@@ -26,6 +28,7 @@ export class InjectsService {
    private readonly rt: RealtimeGateway,
    private readonly chat: ChatService,
    private readonly social: SocialService,
    private readonly news: NewsService,
  ) {}

  private async assertExercise(tenantId: string, exerciseId: string): Promise<void> {
@@ -38,10 +41,31 @@ export class InjectsService {
  /** Construit `targeting` + `payload` selon le canal, avec validation. */
  private buildPayload(dto: CreateInjectDto): {
    targeting: InjectTargeting;
    payload: EmailPayload | ChatInjectPayload | SocialInjectPayload | CallInjectPayload;
    payload:
      | EmailPayload
      | ChatInjectPayload
      | SocialInjectPayload
      | CallInjectPayload
      | NewsInjectPayload;
  } {
    const channel = dto.channel ?? 'EMAIL';

    if (channel === 'NEWS') {
      if (!dto.headline || !dto.newsSource || !dto.body) {
        throw new BadRequestException('Un inject presse requiert un titre, une source et un contenu');
      }
      return {
        targeting: { mode: 'ALL' },
        payload: {
          outlet: dto.outlet ?? 'GENERIC',
          headline: dto.headline,
          source: dto.newsSource,
          category: dto.newsCategory,
          body: dto.body,
        },
      };
    }

    if (channel === 'CHAT') {
      if (!dto.channelId || !dto.body) {
        throw new BadRequestException('Un inject chat requiert un canal et un message');
@@ -195,6 +219,15 @@ export class InjectsService {
        shares: p.shares,
        comments: p.comments,
      });
    } else if (inject.channel === 'NEWS') {
      const p = inject.payload as unknown as NewsInjectPayload;
      await this.news.create(tenantId, inject.exerciseId, {
        outlet: p.outlet,
        headline: p.headline,
        source: p.source,
        category: p.category,
        body: p.body,
      });
    } else if (inject.channel === 'CALL') {
      // Rappel cote animateur : aucune diffusion aux joueurs.
      const p = inject.payload as unknown as CallInjectPayload;
+86 −6
Original line number Diff line number Diff line
@@ -248,13 +248,14 @@ function CreateInjectForm({
  onCreated: () => Promise<void>;
}): JSX.Element {
  const { t } = useT();
  const [channel, setChannel] = useState<'EMAIL' | 'CHAT' | 'SOCIAL' | 'CALL'>('EMAIL');
  const [channel, setChannel] = useState<'EMAIL' | 'CHAT' | 'SOCIAL' | 'CALL' | 'NEWS'>('EMAIL');
  const [title, setTitle] = useState('');
  const [callTo, setCallTo] = useState('');
  const [subject, setSubject] = useState('');
  const [body, setBody] = useState('');
  const [trigger, setTrigger] = useState<'SCHEDULED' | 'MANUAL'>('MANUAL');
  const [minute, setMinute] = useState(5);
  const [schedHours, setSchedHours] = useState(0);
  const [schedMin, setSchedMin] = useState(5);
  const [senderCharId, setSenderCharId] = useState('');
  const [docs, setDocs] = useState<ApiDocument[]>([]);
  const [attachIds, setAttachIds] = useState<string[]>([]);
@@ -264,6 +265,12 @@ function CreateInjectForm({
  const [authorName, setAuthorName] = useState('');
  const [authorHandle, setAuthorHandle] = useState('');
  const [likes, setLikes] = useState(0);
  const [outlet, setOutlet] = useState<'GENERIC' | 'LEMONDE' | 'FIGARO' | 'FRANCEINFO' | 'OUESTFRANCE'>(
    'GENERIC',
  );
  const [headline, setHeadline] = useState('');
  const [newsSource, setNewsSource] = useState('');
  const [newsCategory, setNewsCategory] = useState('');
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
@@ -278,13 +285,15 @@ function CreateInjectForm({
      title,
      channel,
      trigger,
      offsetSeconds: trigger === 'SCHEDULED' ? minute * 60 : undefined,
      offsetSeconds: trigger === 'SCHEDULED' ? (schedHours * 60 + schedMin) * 60 : undefined,
    };
    let input: InjectInput;
    if (channel === 'CHAT') {
      input = { ...base, senderCharId: senderCharId || undefined, channelId, body };
    } else if (channel === 'SOCIAL') {
      input = { ...base, network, authorName, authorHandle, body, likes };
    } else if (channel === 'NEWS') {
      input = { ...base, outlet, headline, newsSource, newsCategory: newsCategory || undefined, body };
    } else if (channel === 'CALL') {
      input = { ...base, to: callTo || undefined, body };
    } else {
@@ -307,6 +316,8 @@ function CreateInjectForm({
      setAuthorName('');
      setAuthorHandle('');
      setLikes(0);
      setHeadline('');
      setNewsCategory('');
      await onCreated();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Création impossible'));
@@ -321,6 +332,7 @@ function CreateInjectForm({
          <option value="EMAIL">{t('Email')}</option>
          <option value="CHAT">{t('Chat')}</option>
          <option value="SOCIAL">{t('Réseau social')}</option>
          <option value="NEWS">{t('Presse')}</option>
          <option value="CALL">{t('Appel (rappel animateur)')}</option>
        </select>
        <input placeholder={t('Titre (interne)')} value={title} onChange={(e) => setTitle(e.target.value)} required />
@@ -334,9 +346,20 @@ function CreateInjectForm({
            <input
              type="number"
              min={0}
              value={minute}
              onChange={(e) => setMinute(Number(e.target.value))}
              style={{ width: 64 }}
              value={schedHours}
              onChange={(e) => setSchedHours(Math.max(0, Math.floor(Number(e.target.value))))}
              style={{ width: 56 }}
              aria-label={t('Heures')}
            />
            h
            <input
              type="number"
              min={0}
              max={59}
              value={schedMin}
              onChange={(e) => setSchedMin(Math.min(59, Math.max(0, Math.floor(Number(e.target.value)))))}
              style={{ width: 56 }}
              aria-label={t('Minutes')}
            />
            min
          </label>
@@ -414,6 +437,7 @@ function CreateInjectForm({
            <select value={network} onChange={(e) => setNetwork(e.target.value as typeof network)}>
              <option value="X">X</option>
              <option value="LINKEDIN">LinkedIn</option>
              <option value="INSTAGRAM">Instagram</option>
            </select>
            <input placeholder={t('Nom du compte')} value={authorName} onChange={(e) => setAuthorName(e.target.value)} required />
            <input placeholder={t('@identifiant')} value={authorHandle} onChange={(e) => setAuthorHandle(e.target.value)} required />
@@ -440,6 +464,62 @@ function CreateInjectForm({
        </>
      )}

      {channel === 'NEWS' && (
        <>
          <div className="row">
            <select
              value={outlet}
              onChange={(e) => {
                const o = e.target.value as typeof outlet;
                setOutlet(o);
                const names: Record<typeof o, string> = {
                  GENERIC: '',
                  LEMONDE: 'Le Monde',
                  FIGARO: 'Le Figaro',
                  FRANCEINFO: 'franceinfo',
                  OUESTFRANCE: 'Ouest-France',
                };
                if (o !== 'GENERIC') setNewsSource(names[o]);
              }}
            >
              <option value="GENERIC">{t('Générique (Le Fil)')}</option>
              <option value="LEMONDE">Le Monde</option>
              <option value="FIGARO">Le Figaro</option>
              <option value="FRANCEINFO">franceinfo</option>
              <option value="OUESTFRANCE">Ouest-France</option>
            </select>
            <input
              placeholder={t('Source (média)')}
              value={newsSource}
              onChange={(e) => setNewsSource(e.target.value)}
              required
            />
            <input
              placeholder={t('Rubrique (optionnel)')}
              value={newsCategory}
              onChange={(e) => setNewsCategory(e.target.value)}
            />
          </div>
          <div className="row">
            <input
              placeholder={t("Titre de l'article")}
              value={headline}
              onChange={(e) => setHeadline(e.target.value)}
              required
            />
          </div>
          <div className="row">
            <textarea
              placeholder={t("Corps de l'article")}
              value={body}
              onChange={(e) => setBody(e.target.value)}
              rows={3}
              required
            />
          </div>
        </>
      )}

      <div className="row">
        <button className="btn primary" type="submit">
          {t('Ajouter au chronogramme')}
+6 −1
Original line number Diff line number Diff line
@@ -42,7 +42,7 @@ export interface PostInput {

export interface InjectInput {
  title: string;
  channel?: 'EMAIL' | 'CHAT' | 'SOCIAL' | 'CALL';
  channel?: 'EMAIL' | 'CHAT' | 'SOCIAL' | 'CALL' | 'NEWS';
  trigger: 'SCHEDULED' | 'MANUAL';
  offsetSeconds?: number;
  senderCharId?: string;
@@ -61,6 +61,11 @@ export interface InjectInput {
  authorName?: string;
  authorHandle?: string;
  likes?: number;
  // Presse
  outlet?: 'GENERIC' | 'LEMONDE' | 'FIGARO' | 'FRANCEINFO' | 'OUESTFRANCE';
  headline?: string;
  newsSource?: string;
  newsCategory?: string;
}

export interface LogInput {
Loading