All files / src/services subscription.ts

100% Statements 92/92
100% Branches 38/38
100% Functions 4/4
100% Lines 92/92

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 1271x 1x     1x     1x                   1x     1x 1x 1x 1x 1x     1x 1x 1x 1x     1x 5x   5x 5x 5x 4x 4x 5x 5x 5x     1x 5x   5x 2x 3x 3x 3x 3x 3x 3x 3x 3x   1x 5x 5x 5x 5x 5x 5x 5x 5x 5x   1x 12x 12x 12x 12x 11x 1x 12x     12x 12x 12x 12x 12x             12x 2x 12x 12x 10x 10x 2x 2x 1x 1x 2x 1x 1x 2x 2x 10x   2x 2x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 1x 12x 12x 12x 12x  
import { Effect, Stream, Schema } from 'effect';
import { createOrpcClient } from '@/libs/orpcClient';
 
// Services
import { auth } from '@/services/firebase';
 
// Types
import {
  EventType,
  SubscribeEvent,
  MenuSocketUpdate,
  WSReactionData,
  WSSentimentData,
  OwnCommentChangePayload,
} from '@/types/meal';
 
// Constants
import { ERROR_MESSAGES } from '@/constants/messages';
 
// Reaction counts ride on every lunch-menu event.
export const ReactionPayload = Schema.Struct({
  id: Schema.String,
  likes: Schema.Number,
  dislikes: Schema.Number,
});
 
// Only present on classify-completion events (extra authorId field is ignored here).
export const ClassifiedCommentPayload = Schema.Struct({
  id: Schema.String,
  sentiment: Schema.String,
});
 
// Decode reaction counts; an invalid/missing payload yields undefined instead of failing.
const extractReactionUpdate = (
  data: unknown,
): Effect.Effect<WSReactionData | undefined> =>
  Schema.decodeUnknown(ReactionPayload)(data).pipe(
    Effect.map(
      (parsed): WSReactionData => ({
        [parsed.id]: { likes: parsed.likes, dislikes: parsed.dislikes },
      }),
    ),
    Effect.orElseSucceed(() => undefined),
  );
 
// Decode the classified comment (keyed by commentId) when present.
const decodeSentiment = (
  data: SubscribeEvent['data'],
): Effect.Effect<WSSentimentData | undefined> =>
  data.classifiedComment === undefined
    ? Effect.succeed(undefined)
    : Schema.decodeUnknown(ClassifiedCommentPayload)(
        data.classifiedComment,
      ).pipe(
        Effect.map(
          (comment): WSSentimentData => ({ [comment.id]: comment.sentiment }),
        ),
        Effect.orElseSucceed(() => undefined),
      );
 
const buildUpdate = (event: SubscribeEvent): Effect.Effect<MenuSocketUpdate> =>
  Effect.all({
    reaction: extractReactionUpdate(event.data),
    sentiment: decodeSentiment(event.data),
  }).pipe(
    Effect.map(({ reaction, sentiment }) => ({
      ...(reaction ? { reaction } : {}),
      ...(sentiment ? { sentiment } : {}),
    })),
  );
 
export const subscribeService = (origin?: string) =>
  Effect.scoped(
    Effect.gen(function* (_) {
      const user = auth.currentUser;
      const token = user
        ? yield* _(Effect.promise(() => user.getIdToken(true)))
        : undefined;
      const client = createOrpcClient(token, origin);
 
      // Wrap AsyncGenerator in Promise.resolve so Effect.promise accepts it
      const iterator: AsyncGenerator<SubscribeEvent> = yield* _(
        Effect.promise(() =>
          Promise.resolve(client.socket.subscribeLunchMenu(undefined, {})),
        ),
      );
 
      // Convert AsyncGenerator → Stream and normalize each event into a MenuSocketUpdate:
      // LUNCH_MENU → reaction counts and/or classified comment;
      // COMMENT_CHANGE → comment delta + that day's counts (dashboard/admin);
      // OWN_COMMENT_CHANGE → user's own comment delta (regular users, flat payload);
      // unknown types → {}.
      return Stream.fromAsyncIterable<SubscribeEvent, Error>(iterator, (e) =>
        e instanceof Error ? e : new Error(ERROR_MESSAGES.DEFAULT),
      ).pipe(
        Stream.mapEffect((event) => {
          if (event.type === EventType.LUNCH_MENU) return buildUpdate(event);
          if (event.type === EventType.COMMENT_CHANGE) {
            return Effect.succeed<MenuSocketUpdate>({
              ...(event.data.commentChange
                ? { commentChange: event.data.commentChange }
                : {}),
              ...(event.data.dayCounts
                ? { dayCounts: event.data.dayCounts }
                : {}),
            });
          }
          if (event.type === EventType.OWN_COMMENT_CHANGE) {
            // OWN_COMMENT_CHANGE comes as flat payload: menuId, action, comment at data root
            const ownCommentChange: OwnCommentChangePayload | undefined =
              event.data.menuId && event.data.action && event.data.comment
                ? {
                    menuId: event.data.menuId,
                    action: event.data.action,
                    comment: event.data.comment,
                  }
                : undefined;
            return Effect.succeed<MenuSocketUpdate>({
              ...(ownCommentChange ? { ownCommentChange } : {}),
            });
          }
          return Effect.succeed<MenuSocketUpdate>({});
        }),
      );
    }),
  );