- + @for (experiment of experiments(); track experiment.name) { diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.ts index 43390d79..21fc4fa2 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { DiscussItemsMessage, dateStrOfTimestamp } from '@llm-mediation-experiments/utils'; +import { DiscussItemsMessage, ITEMS, dateStrOfTimestamp } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-chat-discuss-items-message', @@ -12,4 +12,5 @@ export class ChatDiscussItemsMessageComponent { @Input() discussItemsMessage!: DiscussItemsMessage; readonly dateStrOfTimestamp = dateStrOfTimestamp; + readonly ITEMS = ITEMS; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.html index 80ef822a..0f1e5a1b 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.html @@ -1,7 +1,9 @@
{{ dateStrOfTimestamp(message.timestamp) }}
- +
{{ message.text }}
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.ts index 09a22e39..d9aa8d63 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.ts @@ -1,11 +1,10 @@ -import { Component, Inject, Input, Signal, computed } from '@angular/core'; +import { Component, Input, Signal, computed } from '@angular/core'; import { - ParticipantExtended, + ParticipantProfile, UserMessage, dateStrOfTimestamp, - lookupTable, } from '@llm-mediation-experiments/utils'; -import { EXPERIMENT_PROVIDER_TOKEN, ExperimentProvider } from 'src/lib/provider-tokens'; +import { ParticipantService } from 'src/app/services/participant.service'; import { ChatUserProfileComponent } from '../chat-user-profile/chat-user-profile.component'; @Component({ @@ -18,11 +17,11 @@ import { ChatUserProfileComponent } from '../chat-user-profile/chat-user-profile export class ChatUserMessageComponent { @Input() message!: UserMessage; - lookup: Signal>; + participants: Signal>; - constructor(@Inject(EXPERIMENT_PROVIDER_TOKEN) experimentProvider: ExperimentProvider) { - this.lookup = computed(() => - lookupTable(experimentProvider.get()()?.participants ?? [], 'uid'), + constructor(participantService: ParticipantService) { + this.participants = computed( + () => participantService.experiment()?.experiment()?.participants ?? {}, ); } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html index 15135a87..c7871aeb 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html @@ -1,8 +1,10 @@
Other chat members
- @for (user of this.otherParticipants(); track user.uid) { - @if (user.workingOnStageName === this.participant.userData()?.workingOnStageName) { + @for (user of participantService.otherParticipants(); track user.publicId) { + @if ( + user.workingOnStageName === participantService.participant()?.profile()?.workingOnStageName + ) {
avatar
@@ -22,7 +24,7 @@ Discussion
- @for (message of messages(); track $index) { + @for (message of chat?.messages(); track $index) {
@if (message.messageType === 'userMessage') { @@ -40,7 +42,9 @@
- + Message to send @@ -49,7 +53,7 @@ type="submit" color="primary" mat-button - [disabled]="this.readyToEndChat() || !this.message.valid" + [disabled]="readyToEndChat() || !message.valid" > Send @@ -59,8 +63,8 @@
I'm done with chatting and ready to move on @@ -74,19 +78,19 @@
{{ currentRatingsToDiscuss().item1.name }} - {{ currentRatingsToDiscuss().item1.name }} + {{ ITEMS[currentRatingsToDiscuss().item1].name }}
{{ currentRatingsToDiscuss().item2.name }} - {{ currentRatingsToDiscuss().item2.name }} + {{ ITEMS[currentRatingsToDiscuss().item2].name }}
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts index 0f8e77c7..ed764df0 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts @@ -6,50 +6,18 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { - Component, - Inject, - Input, - OnDestroy, - Signal, - WritableSignal, - computed, - effect, - signal, - untracked, -} from '@angular/core'; +import { Component, Input, Signal, WritableSignal, computed, signal } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { - DiscussItemsMessage, - ExpStageChatAboutItems, - ItemPair, - Message, - MessageType, - ParticipantExtended, - ReadyToEndChat, - getDefaultItemPair, - mergeByKey, -} from '@llm-mediation-experiments/utils'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { Unsubscribe } from 'firebase/firestore'; -import { - toggleChatMutation, - updateChatStageMutation, - userMessageMutation, -} from 'src/lib/api/mutations'; -import { Participant } from 'src/lib/participant'; -import { - EXPERIMENT_PROVIDER_TOKEN, - ExperimentProvider, - PARTICIPANT_PROVIDER_TOKEN, - ParticipantProvider, -} from 'src/lib/provider-tokens'; -import { localStorageTimer } from 'src/lib/utils/angular.utils'; -import { chatMessagesSubscription, firestoreDocSubscription } from 'src/lib/utils/firestore.utils'; +import { ITEMS, ItemPair, StageKind, getDefaultItemPair } from '@llm-mediation-experiments/utils'; + +import { AppStateService } from 'src/app/services/app-state.service'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; +import { ChatRepository } from 'src/lib/repositories/chat.repository'; +import { localStorageTimer, subscribeSignal } from 'src/lib/utils/angular.utils'; import { ChatDiscussItemsMessageComponent } from './chat-discuss-items-message/chat-discuss-items-message.component'; import { ChatMediatorMessageComponent } from './chat-mediator-message/chat-mediator-message.component'; import { ChatUserMessageComponent } from './chat-user-message/chat-user-message.component'; @@ -77,130 +45,56 @@ const TIMER_SECONDS = 60; // 1 minute between item pairs for discussions templateUrl: './exp-chat.component.html', styleUrl: './exp-chat.component.scss', }) -export class ExpChatComponent implements OnDestroy { +export class ExpChatComponent { + private _stage?: CastViewingStage; + readonly ITEMS = ITEMS; + // Reload the internal logic dynamically when the stage changes @Input({ required: true }) - set stage(value: ExpStageChatAboutItems) { + set stage(value: CastViewingStage) { this._stage = value; - this.everyoneReachedTheChat = this.participant.everyoneReachedCurrentStage(this.stage.name); - - // Initialize the current rating to discuss with the first available pair - const { id1, id2 } = this.stage.config.ratingsToDiscuss[0]; - this.currentRatingsToDiscuss = signal({ - item1: this.stage.config.items[id1], - item2: this.stage.config.items[id2], - }); - - this.unsubscribeMessages = chatMessagesSubscription( - this.stage.config.chatId, - (incomingMessages) => { - // Merge incoming and current message, giving incoming messages priority. Messages are uniquely identified by their uid. - this.messages.set(mergeByKey(this.messages(), incomingMessages, 'uid')); - - // Find if new discuss items message have arrived - const last = incomingMessages.find( - (m) => m.messageType === MessageType.DiscussItemsMessage, - ) as DiscussItemsMessage | undefined; - - if (last) this.currentRatingsToDiscuss.set(last.itemPair); - }, + this.everyoneReachedTheChat = computed(() => + this.participantService.experiment()!.everyoneReachedStage(this.stage.config().name)(), ); - // Firestore subscription for ready to end chat - this.unsubscribeReadyToEndChat = firestoreDocSubscription( - `participants_ready_to_end_chat/${this.stage.config.chatId}`, - (d) => { - if (this.discussingPairIndex() !== d?.currentPair && d) - this.discussingPairIndex.set(d?.currentPair); - }, - ); + // On config change, extract the relevant chat repository + subscribeSignal(this.stage.config, (config) => { + // Extract the relevant chat repository for this chat + this.chat = this.appState.chats.get({ + chatId: config.chatId, + experimentId: this.participantService.experimentId()!, + participantId: this.participantService.participantId()!, + }); + + // Initialize the current rating to discuss with the first available pair + const { item1, item2 } = config.chatConfig.ratingsToDiscuss[0]; + this.currentRatingsToDiscuss = signal({ item1, item2 }); + }); } get stage() { - return this._stage as ExpStageChatAboutItems; + return this._stage as CastViewingStage; } - public _stage?: ExpStageChatAboutItems; - - public participant: Participant; - public otherParticipants: Signal; public everyoneReachedTheChat: Signal; + public readyToEndChat = signal(false); - // Extracted stage data + // Extracted stage data (needed ?) public currentRatingsToDiscuss: WritableSignal; - // Queries - private client = injectQueryClient(); - - // Message subscription - public messages: WritableSignal; - private unsubscribeMessages: Unsubscribe | undefined; - - // Ready to end chat subscription - private unsubscribeReadyToEndChat: Unsubscribe | undefined; - // Message mutation & form - public messageMutation = userMessageMutation(); public message = new FormControl('', Validators.required); - // Chat completion mutation - public finishChatMutation = updateChatStageMutation(this.client, () => - this.participant.navigateToNextStage(), - ); - - public discussingPairIndex = signal(0); - - public toggleMutation = toggleChatMutation(); - public readyToEndChat: WritableSignal = signal(false); // Frontend-only, no need to have fine-grained backend sync for this - public timer = localStorageTimer('chat-timer', TIMER_SECONDS, () => this.toggleEndChat()); // 1 minute timer + public chat: ChatRepository | undefined; constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ParticipantProvider, - @Inject(EXPERIMENT_PROVIDER_TOKEN) experimentProvider: ExperimentProvider, + private appState: AppStateService, + public participantService: ParticipantService, ) { - this.participant = participantProvider.get(); // Get the participant instance - // Extract stage data this.everyoneReachedTheChat = signal(false); this.currentRatingsToDiscuss = signal(getDefaultItemPair()); - - this.otherParticipants = computed( - () => - experimentProvider - .get()() - ?.participants.filter(({ uid }) => uid !== this.participant.userData()?.uid) ?? [], - ); - - // Firestore subscription for messages - this.messages = signal([]); - - effect( - () => { - if ( - this.participant.workingOnStage()?.name !== this.stage.name || - !this.everyoneReachedTheChat() - ) - return; // Continue only if this stage is active - - const index = this.discussingPairIndex(); - - if (index < this.stage.config.ratingsToDiscuss.length) { - // Update to the next, reset the counter. - this.timer.reset(TIMER_SECONDS); - this.readyToEndChat.set(false); - } else { - // The chat experiment has ended - this.finishChatMutation.mutate({ - uid: untracked(this.participant.userData)!.uid, - name: this.stage.name, - data: { readyToEndChat: true }, - ...this.participant.getStageProgression(), - }); - } - }, - { allowSignalWrites: true }, - ); } isSilent() { @@ -211,30 +105,28 @@ export class ExpChatComponent implements OnDestroy { sendMessage() { if (!this.message.valid) return; - this.messageMutation.mutate({ - chatId: this.stage.config.chatId, - text: this.message.value!, - fromUserId: this.participant.userData()!.uid, - }); + // TODO: use new backend + // this.messageMutation.mutate({ + // chatId: this.stage.config.chatId, + // text: this.message.value!, + // fromUserId: this.participant.userData()!.uid, + // }); this.message.setValue(''); } toggleEndChat() { if (this.readyToEndChat()) return; - - this.readyToEndChat.set(true); - this.toggleMutation.mutate({ - chatId: this.stage.config.chatId, - participantId: this.participant.userData()!.uid, - readyToEndChat: true, - }); + // TODO: use new backend + // this.toggleMutation.mutate({ + // chatId: this.stage.config.chatId, + // participantId: this.participant.userData()!.uid, + // readyToEndChat: true, + // }); this.message.disable(); this.timer.remove(); } - - ngOnDestroy() { - this.unsubscribeMessages?.(); - this.unsubscribeReadyToEndChat?.(); - } } + +// TODO: faire fonctionner le reste du html, puis go courses. +// ensuite yaura les petits "sous-messages" à voir. diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html index 164c90c4..9568c66a 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html @@ -5,7 +5,7 @@
} @else {
- The leader is {{ finalLeader() }} + The leader is {{ results()?.currentLeader }}
} diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts index 8ab77c9e..9745b1fb 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts @@ -1,12 +1,11 @@ -import { Component, Inject, Input, signal, Signal } from '@angular/core'; +import { Component, computed, Input, signal, Signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; - -import { ExpStageVoteReveal, VoteReveal } from '@llm-mediation-experiments/utils'; -import { ProviderService } from 'src/app/services/provider.service'; -import { updateLeaderRevealStageMutation } from 'src/lib/api/mutations'; -import { Participant } from 'src/lib/participant'; -import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; +import { + assertCast, + StageKind, + VoteForLeaderStagePublicData, +} from '@llm-mediation-experiments/utils'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; @Component({ selector: 'app-exp-leader-reveal', @@ -18,48 +17,38 @@ import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; export class ExpLeaderRevealComponent { // Reload the internal logic dynamically when the stage changes @Input({ required: true }) - set stage(value: ExpStageVoteReveal) { + set stage(value: CastViewingStage) { this._stage = value; - this.stageData = this.stage.config; - this.everyoneReachedThisStage = this.participant.everyoneReachedCurrentStage(this.stage.name); + this.everyoneReachedThisStage = computed(() => + this.participantService.experiment()!.everyoneReachedStage(this.stage.config().name)(), + ); + + // Extract results from the public vote for leader stage data + this.results = computed(() => + assertCast( + this.participantService.experiment()!.publicStageDataMap[ + this.stage.config().pendingVoteStageName + ]!(), + StageKind.VoteForLeader, + ), + ); } - private queryClient = injectQueryClient(); - private mutationReveal = updateLeaderRevealStageMutation(this.queryClient, () => - this.participant.navigateToNextStage(), - ); - - get stage(): ExpStageVoteReveal { - return this._stage as ExpStageVoteReveal; + get stage() { + return this._stage as CastViewingStage; } - private _stage?: ExpStageVoteReveal; - - public participant: Participant; - public stageData: VoteReveal; + private _stage?: CastViewingStage; public everyoneReachedThisStage: Signal; - public finalLeader: Signal; - - constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - ) { - this.participant = participantProvider.get(); - this.stageData = this.stage?.config; // This will truly be initialized in ngOnInit. this.stage can be undefined here + public results: Signal = signal(undefined); - this.everyoneReachedThisStage = signal(false); - - // TODO: use the new backend - this.finalLeader = signal('TODO'); + constructor(private participantService: ParticipantService) { + this.everyoneReachedThisStage = signal(false); } nextStep() { - this.mutationReveal.mutate({ - data: undefined, - name: this.stage.name, - ...this.participant.getStageProgression(), - uid: this.participant.userData()!.uid, - }); + // TODO: use the new backend } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.html index 042bc535..4bbe3258 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.html @@ -1,9 +1,9 @@
- @for (user of this.otherParticipants(); track user.uid) { - - + @for (user of participantService.otherParticipants(); track user.publicId) { + + {{ Vote.Positive }} @@ -14,7 +14,12 @@ {{ Vote.Negative }} - diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts index 6e5fa62c..7792cc14 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts @@ -6,8 +6,7 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { Component, computed, Inject, Input, Signal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { Component, Input } from '@angular/core'; import { FormBuilder, FormControl, @@ -18,17 +17,11 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatRadioModule } from '@angular/material/radio'; import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { ProviderService } from 'src/app/services/provider.service'; import { updateLeaderVoteStageMutation } from 'src/lib/api/mutations'; -import { Participant } from 'src/lib/participant'; -import { - EXPERIMENT_PROVIDER_TOKEN, - ExperimentProvider, - PARTICIPANT_PROVIDER_TOKEN, -} from 'src/lib/provider-tokens'; -import { ExpStageVotes, ParticipantExtended, Vote, Votes } from '@llm-mediation-experiments/utils'; -import { forbiddenValueValidator } from 'src/lib/utils/angular.utils'; +import { StageKind, Vote, VoteForLeaderStageAnswer } from '@llm-mediation-experiments/utils'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; +import { forbiddenValueValidator, subscribeSignal } from 'src/lib/utils/angular.utils'; @Component({ selector: 'app-exp-leader-vote', @@ -40,55 +33,36 @@ import { forbiddenValueValidator } from 'src/lib/utils/angular.utils'; export class ExpLeaderVoteComponent { // Reload the internal logic dynamically when the stage changes @Input({ required: true }) - set stage(value: ExpStageVotes) { + set stage(value: CastViewingStage) { this._stage = value; - this.voteConfig = this.stage.config; - this.initializeForm(); + // Update the form when the answers change (this will also initialize the form the first time) + if (this.stage.answers) + subscribeSignal(this.stage.answers, (answers: VoteForLeaderStageAnswer) => + this.initializeForm(answers), + ); + else this.initializeForm({ votes: {}, kind: StageKind.VoteForLeader }); // Initialize the form with empty answers } - get stage(): ExpStageVotes { - return this._stage as ExpStageVotes; + get stage() { + return this._stage as CastViewingStage; } - private _stage?: ExpStageVotes; - - public otherParticipants: Signal; - - readonly Vote = Vote; - - public participant: Participant; - public voteConfig: Votes; - - public votesForm: FormGroup; - + private _stage?: CastViewingStage; private client = injectQueryClient(); // Vote completion mutation - public voteMutation = updateLeaderVoteStageMutation(this.client, () => - this.participant.navigateToNextStage(), - ); + public voteMutation = updateLeaderVoteStageMutation(this.client); + readonly Vote = Vote; + public votesForm: FormGroup; constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - @Inject(EXPERIMENT_PROVIDER_TOKEN) experimentProvider: ExperimentProvider, + public participantService: ParticipantService, fb: FormBuilder, ) { - this.participant = participantProvider.get(); - this.voteConfig = this.stage?.config; - this.votesForm = fb.group({ votes: fb.group({}), }); - - this.otherParticipants = computed( - () => - experimentProvider - .get()() - ?.participants.filter(({ uid }) => uid !== this.participant.userData()?.uid) ?? [], - ); - - toObservable(this.otherParticipants).subscribe(() => this.initializeForm()); } get votes() { @@ -111,22 +85,17 @@ export class ExpLeaderVoteComponent { } nextStep() { - this.voteMutation.mutate({ - data: this.votesForm.value.votes, - name: this.stage.name, - uid: this.participant.userData()!.uid, - ...this.participant.getStageProgression(), - }); + // TODO: use new backend } /** Call this when the input or the other participants signal change in order to stay up to date */ - initializeForm() { + initializeForm(answers: VoteForLeaderStageAnswer) { this.clearForm(); - for (const p of this.otherParticipants()) { + for (const p of this.participantService.otherParticipants()) { this.votes.addControl( - p.uid, + p.publicId, new FormControl( - this.voteConfig[p.uid] || Vote.NotRated, + answers.votes[p.publicId] || Vote.NotRated, forbiddenValueValidator(Vote.NotRated), ), ); diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.html index a3ce96c8..4d627a44 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.html @@ -1,28 +1,28 @@
- @for (question of stage.config.questions; track question.id; let index = $index) { + @for (question of stage.config().questions; track index; let index = $index) { @switch (question.kind) { @case (SurveyQuestionKind.Check) { } @case (SurveyQuestionKind.Text) { } @case (SurveyQuestionKind.Rating) { } @case (SurveyQuestionKind.Scale) { } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts index 8edcc05b..cef79682 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts @@ -6,7 +6,7 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { Component, Inject, Input } from '@angular/core'; +import { Component, Input, signal } from '@angular/core'; import { FormArray, FormBuilder, @@ -18,21 +18,18 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSliderModule } from '@angular/material/slider'; -import { ProviderService } from 'src/app/services/provider.service'; -import { Participant } from 'src/lib/participant'; - import { MatButtonModule } from '@angular/material/button'; import { - ExpStageSurvey, + StageKind, SurveyQuestionKind, SurveyStageUpdate, - questionAsKind, + assertCast, } from '@llm-mediation-experiments/utils'; import { injectQueryClient } from '@tanstack/angular-query-experimental'; +import { CastViewingStage } from 'src/app/services/participant.service'; import { updateSurveyStageMutation } from 'src/lib/api/mutations'; -import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; import { MutationType } from 'src/lib/types/tanstack.types'; -import { buildQuestionForm } from 'src/lib/utils/angular.utils'; +import { buildQuestionForm, subscribeSignals } from 'src/lib/utils/angular.utils'; import { SurveyCheckQuestionComponent } from './survey-check-question/survey-check-question.component'; import { SurveyRatingQuestionComponent } from './survey-rating-question/survey-rating-question.component'; import { SurveyScaleQuestionComponent } from './survey-scale-question/survey-scale-question.component'; @@ -59,47 +56,47 @@ import { SurveyTextQuestionComponent } from './survey-text-question/survey-text- export class ExpSurveyComponent { // Reload the internal logic dynamically when the stage changes @Input({ required: true }) - set stage(value: ExpStageSurvey) { + set stage(value: CastViewingStage) { this._stage = value; - // Regenerate the questions - this.questions.clear(); - this.stage.config.questions.forEach((question) => { - this.questions.push(buildQuestionForm(this.fb, question)); - }); + // Regenerate the questions everytime the stage config or answers change + subscribeSignals( + [this.stage.config, this.stage.answers ?? signal(undefined)], + ({ questions }, answers) => { + this.questions.clear(); + questions.forEach((config, i) => { + const answer = answers?.answers[i]; + // The config serves as the source of truth for the question type + // The answer, if defined, will be used to populate the form + this.questions.push(buildQuestionForm(this.fb, config, answer)); + }); + }, + ); } - get stage(): ExpStageSurvey { - return this._stage as ExpStageSurvey; + get stage() { + return this._stage as CastViewingStage; } - private _stage?: ExpStageSurvey; - - public participant: Participant; + private _stage?: CastViewingStage; public questions: FormArray; public surveyForm: FormGroup; readonly SurveyQuestionKind = SurveyQuestionKind; - readonly questionAsKind = questionAsKind; + readonly assertCast = assertCast; queryClient = injectQueryClient(); surveyMutation: MutationType; - constructor( - private fb: FormBuilder, - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - ) { - this.participant = participantProvider.get(); + constructor(private fb: FormBuilder) { this.questions = fb.array([]); this.surveyForm = fb.group({ questions: this.questions, }); - this.surveyMutation = updateSurveyStageMutation(this.queryClient, () => - this.participant.navigateToNextStage(), - ); + this.surveyMutation = updateSurveyStageMutation(this.queryClient); } /** Returns controls for each individual question component */ @@ -108,13 +105,6 @@ export class ExpSurveyComponent { } nextStep() { - this.surveyMutation.mutate({ - name: this.stage.name, - data: { - questions: this.surveyForm.value.questions, - }, - ...this.participant.getStageProgression(), - uid: this.participant.userData()?.uid as string, - }); + // TODO: use new backend } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-check-question/survey-check-question.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-check-question/survey-check-question.component.ts index d77fc39a..4d494383 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-check-question/survey-check-question.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-check-question/survey-check-question.component.ts @@ -1,7 +1,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { CheckQuestion } from '@llm-mediation-experiments/utils'; +import { CheckQuestionConfig } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-survey-check-question', @@ -11,6 +11,6 @@ import { CheckQuestion } from '@llm-mediation-experiments/utils'; styleUrl: './survey-check-question.component.scss', }) export class SurveyCheckQuestionComponent { - @Input() question!: CheckQuestion; + @Input() question!: CheckQuestionConfig; @Input() questionForm!: FormGroup; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html index ee8eb0b7..eaf84466 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html @@ -3,18 +3,18 @@
{{ question.questionText }}
- {{ question.item1.name }} - {{ question.item1.name }} + {{ ITEMS[question.item1].name }} + {{ ITEMS[question.item1].name }} - {{ question.item2.name }} - {{ question.item2.name }} + {{ ITEMS[question.item2].name }} + {{ ITEMS[question.item2].name }} @@ -22,12 +22,18 @@
Confidence: - - {{ question.confidence !== null ? question.confidence.toFixed(1) : 'Not rated' }} + + {{ + questionForm.value['confidence'] !== null + ? questionForm.value['confidence'].toFixed(1) + : 'Not rated' + }}
- @if (question.confidence === 0 || question.confidence === null) { + @if ( + questionForm.value['confidence'] === 0 || questionForm.value['confidence'] === null + ) { 50/50 🤷 } @else { 50/50 🤷 @@ -35,7 +41,7 @@ - @if (question.confidence === 1) { + @if (questionForm.value['confidence'] === 1) { 👍 Full
confidence
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.ts index 3da3c4b4..dea81ad4 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatRadioModule } from '@angular/material/radio'; import { MatSliderModule } from '@angular/material/slider'; -import { RatingQuestion } from '@llm-mediation-experiments/utils'; +import { ITEMS, RatingQuestionConfig } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-survey-rating-question', @@ -12,6 +12,8 @@ import { RatingQuestion } from '@llm-mediation-experiments/utils'; styleUrl: './survey-rating-question.component.scss', }) export class SurveyRatingQuestionComponent { - @Input() question!: RatingQuestion; + @Input() question!: RatingQuestionConfig; @Input() questionForm!: FormGroup; + + readonly ITEMS = ITEMS; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-scale-question/survey-scale-question.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-scale-question/survey-scale-question.component.ts index aef00eef..5daf13fc 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-scale-question/survey-scale-question.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-scale-question/survey-scale-question.component.ts @@ -1,7 +1,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatSliderModule } from '@angular/material/slider'; -import { ScaleQuestion } from '@llm-mediation-experiments/utils'; +import { ScaleQuestionConfig } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-survey-scale-question', @@ -11,6 +11,6 @@ import { ScaleQuestion } from '@llm-mediation-experiments/utils'; styleUrl: './survey-scale-question.component.scss', }) export class SurveyScaleQuestionComponent { - @Input() question!: ScaleQuestion; + @Input() question!: ScaleQuestionConfig; @Input() questionForm!: FormGroup; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-text-question/survey-text-question.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-text-question/survey-text-question.component.ts index 937d1ac4..6f1dd792 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-text-question/survey-text-question.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-text-question/survey-text-question.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { TextQuestion } from '@llm-mediation-experiments/utils'; +import { TextQuestionConfig } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-survey-text-question', @@ -12,6 +12,6 @@ import { TextQuestion } from '@llm-mediation-experiments/utils'; styleUrl: './survey-text-question.component.scss', }) export class SurveyTextQuestionComponent { - @Input() question!: TextQuestion; + @Input() question!: TextQuestionConfig; @Input() questionForm!: FormGroup; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html index 6e6f104f..e810be50 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html @@ -66,7 +66,7 @@

Terms of Service

Please read and accept the following terms of service before proceeding:

    - @for (line of tosLines; track line) { + @for (line of stage.config().tosLines; track line) {
  • {{ line }}
  • }
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts index bb2b0d09..8cb30127 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts @@ -7,7 +7,7 @@ ==============================================================================*/ import { HttpClient } from '@angular/common/http'; -import { Component, Inject, Input, inject } from '@angular/core'; +import { Component, Input, effect, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; @@ -16,11 +16,10 @@ import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { ExpStageTosAndUserProfile, ProfileTOSData } from '@llm-mediation-experiments/utils'; -import { ProviderService } from 'src/app/services/provider.service'; +import { ProfileTOSData, StageKind, UnifiedTimestamp } from '@llm-mediation-experiments/utils'; +import { Timestamp } from 'firebase/firestore'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; import { updateProfileAndTOSMutation } from 'src/lib/api/mutations'; -import { Participant } from 'src/lib/participant'; -import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; import { MutationType } from 'src/lib/types/tanstack.types'; enum Pronouns { @@ -46,30 +45,16 @@ enum Pronouns { }) export class ExpTosAndProfileComponent { // Reload the internal logic dynamically when the stage changes - @Input({ required: true }) - set stage(value: ExpStageTosAndUserProfile) { - this._stage = value; - - // Extract the TOS lines and make them available for the template - this.tosLines = this.stage?.config.tosLines; - } - - get stage(): ExpStageTosAndUserProfile { - return this._stage as ExpStageTosAndUserProfile; - } - - private _stage?: ExpStageTosAndUserProfile; - - public participant: Participant; - public tosLines: string[] | undefined; + @Input({ required: true }) stage!: CastViewingStage; readonly Pronouns = Pronouns; + tosLines: string[] = []; profileFormControl = new FormGroup({ name: new FormControl('', Validators.required), pronouns: new FormControl('', Validators.required), avatarUrl: new FormControl('', Validators.required), - acceptTosTimestamp: new FormControl(null, Validators.required), + acceptTosTimestamp: new FormControl(null, Validators.required), }); http = inject(HttpClient); @@ -78,29 +63,22 @@ export class ExpTosAndProfileComponent { profileMutation: MutationType; value = ''; // Custom pronouns input value - constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - ) { - this.participant = participantProvider.get(); - this.profileMutation = updateProfileAndTOSMutation(this.queryClient, () => - this.participant.navigateToNextStage(), - ); + constructor(participantService: ParticipantService) { + this.profileMutation = updateProfileAndTOSMutation(this.queryClient); - // This WILL already have been fetched by the backend at this point, - // because the auth guard ensures that the participant data is available before rendering this component. - const data = this.participant.userData(); + // Refresh the form data when the participant profile changes + effect(() => { + const profile = participantService.participant()?.profile(); + + if (!profile) return; - if (data) { this.profileFormControl.setValue({ - name: data.name, - pronouns: data.pronouns, - avatarUrl: data.avatarUrl, - acceptTosTimestamp: data.acceptTosTimestamp, + name: profile.name, + pronouns: profile.pronouns, + avatarUrl: profile.avatarUrl, + acceptTosTimestamp: profile.acceptTosTimestamp, }); - if (this.isOtherPronoun(data.pronouns)) { - this.value = data.pronouns; - } - } + }); } isOtherPronoun(s: string) { @@ -117,15 +95,11 @@ export class ExpTosAndProfileComponent { updateCheckboxValue(updatedValue: MatCheckboxChange) { this.profileFormControl.patchValue({ - acceptTosTimestamp: updatedValue.checked ? new Date().toISOString() : null, + acceptTosTimestamp: updatedValue.checked ? Timestamp.now() : null, }); } nextStep() { - this.profileMutation.mutate({ - ...this.profileFormControl.value, - ...this.participant.getStageProgression(), - uid: this.participant.userData()?.uid, - } as ProfileTOSData); + // TODO: refactor with new backend } } diff --git a/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.html b/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.html index 274f585e..c1fb13ec 100644 --- a/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.html @@ -1,44 +1,56 @@
-

{{ this.participant.viewingStage()?.name }} ({{ this.participant.viewingStage()?.kind }})

-
- @switch (this.participant.viewingStage()?.kind) { - @case (StageKind.AcceptTosAndSetProfile) { - + @if (participantService.viewingStage() === undefined) { + Loading... + } @else { +

+ {{ participantService.viewingStage()!.config().name }} ({{ + participantService.viewingStage()!.config().kind + }}) +

+
+ @switch (participantService.viewingStage()!.config().kind) { + @case (StageKind.AcceptTosAndSetProfile) { + + } + @case (StageKind.GroupChat) { + + } + @case (StageKind.TakeSurvey) { + + } + @case (StageKind.VoteForLeader) { + + } + @case (StageKind.RevealVoted) { + + } + @default { +
+ Oh no! It looks like something went wrong, there is no component for + {{ participantService.viewingStage()!.config().kind }} +
+ } } - @case (StageKind.GroupChat) { - - } - @case (StageKind.TakeSurvey) { - - } - @case (StageKind.VoteForLeader) { - - } - @case (StageKind.RevealVoted) { - - } - @default { -
- Oh no! It looks like something went wrong, there is no component for - {{ this.participant.viewingStage()?.kind }} + + @if ( + participantService.viewingStage()!.config().name !== participantService.workingOnStageName() + ) { +
+
} - } - - @if (this.participant.viewingStage()?.name !== this.participant.workingOnStage()?.name) { -
- -
- } -
+
+ }
diff --git a/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.ts b/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.ts index 665fee36..07cae464 100644 --- a/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.ts @@ -6,17 +6,14 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { Component, Inject } from '@angular/core'; +import { Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { StageKind } from '@llm-mediation-experiments/utils'; +import { ParticipantService, assertCastStageSignals } from 'src/app/services/participant.service'; import { ExpChatComponent } from './exp-chat/exp-chat.component'; import { ExpLeaderRevealComponent } from './exp-leader-reveal/exp-leader-reveal.component'; import { ExpLeaderVoteComponent } from './exp-leader-vote/exp-leader-vote.component'; -//import { ExpRatingComponent } from '../exp-rating/exp-rating.component'; -import { StageKind, stageAsKind } from '@llm-mediation-experiments/utils'; -import { ProviderService } from 'src/app/services/provider.service'; -import { Participant } from 'src/lib/participant'; -import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; import { ExpSurveyComponent } from './exp-survey/exp-survey.component'; import { ExpTosAndProfileComponent } from './exp-tos-and-profile/exp-tos-and-profile.component'; @@ -35,13 +32,8 @@ import { ExpTosAndProfileComponent } from './exp-tos-and-profile/exp-tos-and-pro styleUrl: './participant-stage-view.component.scss', }) export class ParticipantStageViewComponent { - public participant: Participant; readonly StageKind = StageKind; - readonly stageAsKind = stageAsKind; + readonly assertCast = assertCastStageSignals; - constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - ) { - this.participant = participantProvider.get(); - } + constructor(public readonly participantService: ParticipantService) {} } diff --git a/webapp/src/app/participant-view/participant-view.component.ts b/webapp/src/app/participant-view/participant-view.component.ts index 7107701c..5ee90b05 100644 --- a/webapp/src/app/participant-view/participant-view.component.ts +++ b/webapp/src/app/participant-view/participant-view.component.ts @@ -17,9 +17,6 @@ import { routeParamSignal, routeQueryStringSignal } from 'src/lib/utils/angular. import { ParticipantService } from '../services/participant.service'; import { ParticipantStageViewComponent } from './participant-stage-view/participant-stage-view.component'; -// NOTE: est-ce qu'on devrait pas provide ici l'expérience ? en tout cas faudra le faire. -// mettre un signal, ou alors balec. mais injecter au bon endroit - @Component({ selector: 'app-participant-view', standalone: true, diff --git a/webapp/src/app/services/participant.service.ts b/webapp/src/app/services/participant.service.ts index 4fc52b7e..bff93ea3 100644 --- a/webapp/src/app/services/participant.service.ts +++ b/webapp/src/app/services/participant.service.ts @@ -1,5 +1,15 @@ -import { Injectable, Signal, computed, signal } from '@angular/core'; -import { CompleteParticipantStage } from '@llm-mediation-experiments/utils'; +/** + * Helper service that exposes participant and experiment data from the app state service in a convenient bundled way. + */ + +import { Injectable, Signal, computed, signal, untracked } from '@angular/core'; +import { + ParticipantProfile, + PublicStageData, + StageAnswer, + StageConfig, + StageKind, +} from '@llm-mediation-experiments/utils'; import { ChatRepository } from 'src/lib/repositories/chat.repository'; import { ExperimentRepository } from 'src/lib/repositories/experiment.repository'; import { ParticipantRepository } from 'src/lib/repositories/participant.repository'; @@ -19,8 +29,11 @@ export class ParticipantService { public participant: Signal = signal(undefined); public experiment: Signal = signal(undefined); - // Convenient signal to agregate stage data - public viewingStage: Signal = signal(undefined); + // Convenience signal to agregate stage data + // This object can be passed directly to subcomponents that need all stage reactive data + public viewingStage: Signal = signal(undefined); + + public otherParticipants: Signal = signal([]); // Stage status signals public completedStageNames: Signal = signal([]); @@ -29,7 +42,7 @@ export class ParticipantService { constructor(public readonly appState: AppStateService) {} - /** Initialize the service with new parameters */ + /** Initialize the service with the participant and experiment IDs */ initialize( experimentId: Signal, participantId: Signal, @@ -56,30 +69,27 @@ export class ParticipantService { return this.appState.experiments.get({ experimentId }); }); - // Load stage config, public data and answers into one object with bound types + // Bundle all stage data together. This attribute has 2 nested signals: + // - The first one changes when the viewing stage name changes + // - The individual nested ones track individual config, public data and answer changes for the current stage this.viewingStage = computed(() => { const currentStage = this.viewingStageName(); const experiment = this.experiment(); const participant = this.participant(); - if ( - !currentStage || - !participant || - !experiment || - !experiment.stageNames().includes(currentStage) - ) - return undefined; + if (!currentStage || !experiment || !participant || experiment.isLoading()) return; - const config = experiment.stageConfigMap()?.[currentStage]; - - if (!config) return undefined; + const config = experiment.stageConfigMap[currentStage]; + const publicData = experiment.publicStageDataMap[currentStage]; + const answers = participant.stageAnswers[currentStage]; + const kind = untracked(config).kind; return { - kind: config.kind, + kind, config, - public: experiment.publicStageDataMap[currentStage]?.(), - answers: participant.stageAnswers[currentStage]?.(), - } as CompleteParticipantStage; + public: publicData, + answers, + }; }); // Recompute the stage status signals @@ -105,6 +115,12 @@ export class ParticipantService { return experiment.stageNames().slice(experiment.stageNames().indexOf(workingOnStageName) + 1); }); + + this.otherParticipants = computed(() => + Object.values(this.experiment()?.experiment()?.participants ?? {}).filter( + ({ publicId }) => publicId !== this.participant()?.profile()?.publicId, + ), + ); } /** Get a chat repository from its ID, bound to the current experiment and participant */ @@ -119,3 +135,40 @@ export class ParticipantService { }); } } + +// ********************************************************************************************* // +// HELPER TYPES AND CASTING FUNCTIONS // +// ********************************************************************************************* // + +/** If a stage is of the given kind, returns a Signal of it. Else, returns undefined. + * This is a cosmetic type to prevent `Signal | undefined` unions. + */ +type SignalOrUndef = Stage extends { kind: Kind } ? Signal : undefined; + +/** ViewingStage's signals, cast to a specific stage kind. */ +export interface CastViewingStage { + kind: K; + config: Signal; + public: SignalOrUndef | undefined; + answers: SignalOrUndef | undefined; +} + +/** Object that exposes a stage's given config, public data and participant answers all at once. */ +interface ViewingStage { + kind: StageKind; + config: Signal; + public: Signal | undefined; + answers: Signal | undefined; +} + +/** Safely cast the full stage bundle signals to a specific stage kind */ +export const assertCastStageSignals = ( + viewingStage: ViewingStage | undefined, + kind: K, +) => { + if (!viewingStage) throw new Error(`Test is undefined`); + if (viewingStage?.kind !== kind) + throw new Error(`Wrong kind ${viewingStage?.kind} for expected kind ${kind}`); + + return viewingStage as CastViewingStage; +}; diff --git a/webapp/src/lib/api/callables.ts b/webapp/src/lib/api/callables.ts index 9d98a633..2619bda0 100644 --- a/webapp/src/lib/api/callables.ts +++ b/webapp/src/lib/api/callables.ts @@ -6,13 +6,12 @@ import { DiscussItemsMessageMutationData, Experiment, ExperimentCreationData, - ExperimentExtended, + ExperimentTemplate, GenericStageUpdate, MediatorMessageMutationData, - ParticipantExtended, + ParticipantProfile, ProfileTOSData, SimpleResponse, - Template, TemplateCreationData, UserMessageMutationData, } from '@llm-mediation-experiments/utils'; @@ -30,7 +29,7 @@ export const experimentsCallable = data( ); export const experimentCallable = data( - httpsCallable<{ experimentUid: string }, ExperimentExtended>(functions, 'experiment'), + httpsCallable<{ experimentUid: string }, Experiment>(functions, 'experiment'), ); export const deleteExperimentCallable = data( @@ -57,7 +56,7 @@ export const mediatorMessageCallable = data( ); export const participantCallable = data( - httpsCallable<{ participantUid: string }, ParticipantExtended>(functions, 'participant'), + httpsCallable<{ participantUid: string }, ParticipantProfile>(functions, 'participant'), ); export const updateProfileAndTOSCallable = data( @@ -74,7 +73,7 @@ export const toggleReadyToEndChatCallable = data( ); export const templatesCallable = data( - httpsCallable>(functions, 'templates'), + httpsCallable>(functions, 'templates'), ); export const createTemplateCallable = data( diff --git a/webapp/src/lib/repositories/experiment.repository.ts b/webapp/src/lib/repositories/experiment.repository.ts index 1a167e19..6ead30c7 100644 --- a/webapp/src/lib/repositories/experiment.repository.ts +++ b/webapp/src/lib/repositories/experiment.repository.ts @@ -1,32 +1,42 @@ import { Signal, WritableSignal, computed, signal } from '@angular/core'; import { Experiment, PublicStageData, StageConfig } from '@llm-mediation-experiments/utils'; -import { collection, doc, getDocs, onSnapshot } from 'firebase/firestore'; +import { collection, doc, onSnapshot } from 'firebase/firestore'; import { firestore } from '../api/firebase'; import { BaseRepository } from './base.repository'; export class ExperimentRepository extends BaseRepository { // Internal writable signals private _experiment: WritableSignal = signal(undefined); - private _publicStageDataMap: Record> = {}; - private _stageConfigMap: WritableSignal | undefined> = - signal(undefined); + private _publicStageDataMap: Record | undefined> = {}; + private _stageConfigMap: Record> = {}; + private _stageNames: WritableSignal = signal([]); // Helper signal computed along the stage configs // Expose the signals as read-only public get experiment(): Signal { return this._experiment; } - public get stageConfigMap(): Signal | undefined> { + public get stageConfigMap(): Record> { return this._stageConfigMap; } - public get publicStageDataMap(): Record> { + // Some stages do not have public data, not all signals are guaranteed to be defined for all stages + public get publicStageDataMap(): Record | undefined> { return this._publicStageDataMap; } + public get stageNames(): Signal { + return this._stageNames; + } + // Computed helper signals - public stageNames = computed(() => Object.keys(this._stageConfigMap() || {})); - public isLoading = computed(() => !this._experiment() || !this._stageConfigMap()); + // Loading state: enables knowing when the records are populated and ready to use + private loadingState = { + experiment: signal(true), + publicStageData: signal(true), + config: signal(true), + }; + public isLoading = computed(() => Object.values(this.loadingState).some((signal) => signal())); /** @param uid Experiment unique identifier (firestore document id) */ constructor(public readonly uid: string) { @@ -36,6 +46,7 @@ export class ExperimentRepository extends BaseRepository { this.unsubscribe.push( onSnapshot(doc(firestore, 'experiments', uid), (doc) => { this._experiment.set({ id: doc.id, ...doc.data() } as Experiment); + this.loadingState.experiment.set(false); }), ); @@ -49,21 +60,32 @@ export class ExperimentRepository extends BaseRepository { changedDocs.forEach((doc) => { const data = doc.data() as PublicStageData; if (!this._publicStageDataMap[doc.id]) this._publicStageDataMap[doc.id] = signal(data); - else this._publicStageDataMap[doc.id].set(data); + else this._publicStageDataMap[doc.id]!.set(data); }); + this.loadingState.publicStageData.set(false); }), ); + // Subscribe to the config data (although it will not change upon first fetch. We do this to normalize the API) // Fetch the experiment config (it will not change, no subscription is needed) - getDocs(collection(firestore, 'experiments', uid, 'stageConfig')).then((snapshot) => { - const map: Record = {}; + this.unsubscribe.push( + onSnapshot(collection(firestore, 'experiments', uid, 'stages'), (snapshot) => { + let changedDocs = snapshot.docChanges().map((change) => change.doc); + if (changedDocs.length === 0) changedDocs = snapshot.docs; - snapshot.docs.forEach((doc) => { - map[doc.id] = doc.data() as StageConfig; - }); + // Update the stage config signals + changedDocs.forEach((doc) => { + const data = doc.data() as StageConfig; + if (!this._stageConfigMap[doc.id]) this._stageConfigMap[doc.id] = signal(data); + else this._stageConfigMap[doc.id].set(data); + }); - this._stageConfigMap.set(map); - }); + // Load the stage names + this._stageNames.set(Object.keys(this._stageConfigMap)); + + this.loadingState.config.set(false); + }), + ); } /** Build a signal that tracks whether every participant has at least reached the given stage */ diff --git a/webapp/src/lib/repositories/experimenter.repository.ts b/webapp/src/lib/repositories/experimenter.repository.ts index b648327c..f7a433c2 100644 --- a/webapp/src/lib/repositories/experimenter.repository.ts +++ b/webapp/src/lib/repositories/experimenter.repository.ts @@ -24,7 +24,9 @@ export class ExperimenterRepository extends BaseRepository { private _templates: WritableSignal = signal([]); public readonly templatesWithConfigs = new CacheMap(loadExperimentTemplate); - public readonly experimentParticipants = new CacheMap(this.createParticipantsSignal); + public readonly experimentParticipants = new CacheMap((expId: string) => + this.createParticipantsSignal(expId), + ); // Expose the signals as read-only public get experiments(): Signal { diff --git a/webapp/src/lib/repositories/participant.repository.ts b/webapp/src/lib/repositories/participant.repository.ts index afc79873..66081bd9 100644 --- a/webapp/src/lib/repositories/participant.repository.ts +++ b/webapp/src/lib/repositories/participant.repository.ts @@ -7,14 +7,14 @@ import { BaseRepository } from './base.repository'; export class ParticipantRepository extends BaseRepository { // Internal writable signals private _profile: WritableSignal = signal(undefined); - private _stageAnswers: Record> = {}; + private _stageAnswers: Record | undefined> = {}; // Expose the signals as read-only public get profile(): Signal { return this._profile; } - public get stageAnswers(): Record> { + public get stageAnswers(): Record | undefined> { return this._stageAnswers; } @@ -48,7 +48,9 @@ export class ParticipantRepository extends BaseRepository { // Update the public stage data signals changedDocs.forEach((doc) => { - this._stageAnswers[doc.id].set(doc.data() as StageAnswer); + if (!this._stageAnswers[doc.id]) + this._stageAnswers[doc.id] = signal(doc.data() as StageAnswer); + else this._stageAnswers[doc.id]!.set(doc.data() as StageAnswer); }); }, ), diff --git a/webapp/src/lib/utils/angular.utils.ts b/webapp/src/lib/utils/angular.utils.ts index 418410ed..3f40b4d9 100644 --- a/webapp/src/lib/utils/angular.utils.ts +++ b/webapp/src/lib/utils/angular.utils.ts @@ -1,7 +1,7 @@ /** Util functions to manipulate Angular constructs */ -import { Signal, WritableSignal, effect, signal, untracked } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { Signal, WritableSignal, computed, effect, signal, untracked } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl, FormBuilder, @@ -11,12 +11,14 @@ import { } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { - CheckQuestion, - Question, - RatingQuestion, - ScaleQuestion, + CheckQuestionAnswer, + QuestionAnswer, + QuestionConfig, + RatingQuestionAnswer, + ScaleQuestionAnswer, SurveyQuestionKind, - TextQuestion, + TextQuestionAnswer, + assertCastOrUndefined, } from '@llm-mediation-experiments/utils'; import { Observable, map } from 'rxjs'; @@ -84,6 +86,41 @@ export const lazyInitWritable = ( return result; }; +export const assertSignalCast = ( + signalMaybeOfKind: Signal, + kind: K, +): Signal => { + if (signalMaybeOfKind()?.kind === kind) { + return signalMaybeOfKind as Signal; + } else { + throw new Error( + `Given object with kind=${signalMaybeOfKind()?.kind} needs to have kind=${kind}`, + ); + } +}; + +/** Subscribe to a signal's updates. This function does not rely on `effect()` and can be used outside of injection contexts */ +export const subscribeSignal = (_signal: Signal, callback: (value: T) => void) => { + toObservable(_signal).subscribe(callback); +}; + +/** Subscribe to a list of signal updates. This function does not rely on `effect()` and can be used outside of injection contexts */ +export const subscribeSignals = []>( + signals: [...T], + callback: (...args: [...UnwrappedSignalArrayType]) => void, +): void => { + const bundle = computed(() => signals.map((s) => s())); + toObservable(bundle).subscribe((args) => callback(...(args as [...UnwrappedSignalArrayType]))); +}; + +// Helper types for `subscribeSignals` function +/** `Signal` -> `T` */ +type UnwrappedSignalType = T extends Signal ? U : never; +/** `Signal[]` -> `[T]` */ +type UnwrappedSignalArrayType[]> = { + [K in keyof T]: UnwrappedSignalType; +}; + /** Creates a second-counter timer that is synchronized with the local storage in order to resume ticking when reloading the page */ export const localStorageTimer = ( key: string, @@ -139,40 +176,44 @@ export const localStorageTimer = ( // FORM BUILDER // // ********************************************************************************************* // -export const buildTextQuestionForm = (fb: FormBuilder, question: TextQuestion) => +export const buildTextQuestionForm = (fb: FormBuilder, answer?: TextQuestionAnswer) => fb.group({ - answerText: [question.answerText ?? '', Validators.required], + answerText: [answer?.answerText ?? '', Validators.required], }); -export const buildCheckQuestionForm = (fb: FormBuilder, question: CheckQuestion) => +export const buildCheckQuestionForm = (fb: FormBuilder, answer?: CheckQuestionAnswer) => fb.group({ - checkMark: [question.checkMark ?? false], + checkMark: [answer?.checkMark ?? false], }); -export const buildRatingQuestionForm = (fb: FormBuilder, question: RatingQuestion) => +export const buildRatingQuestionForm = (fb: FormBuilder, answer?: RatingQuestionAnswer) => fb.group({ - choice: [question.choice, Validators.required], + choice: [answer?.choice, Validators.required], confidence: [ - question.confidence ?? 0, + answer?.confidence ?? 0, [Validators.required, Validators.min(0), Validators.max(1)], ], }); -export const buildScaleQuestionForm = (fb: FormBuilder, question: ScaleQuestion) => +export const buildScaleQuestionForm = (fb: FormBuilder, answer?: ScaleQuestionAnswer) => fb.group({ - score: [question.score ?? 0, [Validators.required, Validators.min(0), Validators.max(10)]], + score: [answer?.score ?? 0, [Validators.required, Validators.min(0), Validators.max(10)]], }); -export const buildQuestionForm = (fb: FormBuilder, question: Question) => { - switch (question.kind) { +export const buildQuestionForm = ( + fb: FormBuilder, + config: QuestionConfig, + answer?: QuestionAnswer, +) => { + switch (config.kind) { case SurveyQuestionKind.Text: - return buildTextQuestionForm(fb, question); + return buildTextQuestionForm(fb, assertCastOrUndefined(answer, SurveyQuestionKind.Text)); case SurveyQuestionKind.Check: - return buildCheckQuestionForm(fb, question); + return buildCheckQuestionForm(fb, assertCastOrUndefined(answer, SurveyQuestionKind.Check)); case SurveyQuestionKind.Rating: - return buildRatingQuestionForm(fb, question); + return buildRatingQuestionForm(fb, assertCastOrUndefined(answer, SurveyQuestionKind.Rating)); case SurveyQuestionKind.Scale: - return buildScaleQuestionForm(fb, question); + return buildScaleQuestionForm(fb, assertCastOrUndefined(answer, SurveyQuestionKind.Scale)); } }; From e1b1dddd29d299d2eec9ef7aa11de4b1b3967546 Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Wed, 15 May 2024 18:02:06 +0200 Subject: [PATCH 21/35] refactor the cloud functions & mutations (in progress) --- .firebaserc.example | 2 +- README.md | 1 + firestore/firestore.rules | 46 +++- .../src/endpoints/experiments.endpoints.ts | 216 ++++++----------- functions/src/endpoints/messages.endpoints.ts | 81 ++----- .../src/endpoints/participants.endpoints.ts | 188 +++------------ .../src/endpoints/templates.endpoints.ts | 32 --- functions/src/triggers/chats.triggers.ts | 20 +- functions/src/triggers/stages.triggers.ts | 44 +++- .../src/utils/check-stage-progression.ts | 61 ----- functions/src/utils/get-user-chat.ts | 48 ---- functions/src/utils/prefill-leader-votes.ts | 15 -- functions/src/utils/replace-chat-uuid.ts | 20 -- functions/src/utils/type-aliases.ts | 1 - functions/src/validation/chats.validation.ts | 23 ++ .../src/validation/experiments.validation.ts | 47 ++++ functions/src/validation/items.validation.ts | 4 + .../src/validation/messages.validation.ts | 67 ++++-- .../src/validation/questions.validation.ts | 148 ++++++------ functions/src/validation/stages.validation.ts | 225 ++++++++++-------- scripts/src/seed-database.ts | 6 + utils/src/types/api.types.ts | 14 -- utils/src/types/chats.types.ts | 2 +- utils/src/types/items.types.ts | 3 +- utils/src/types/messages.types.ts | 14 +- utils/src/types/participants.types.ts | 10 +- utils/src/types/questions.types.ts | 10 +- utils/src/types/stages.types.ts | 6 +- utils/src/types/votes.types.ts | 48 ++++ .../experiment-monitor.component.ts | 16 +- .../exp-chat/exp-chat.component.ts | 20 +- .../exp-tos-and-profile.component.ts | 18 +- webapp/src/lib/api/callables.ts | 12 +- webapp/src/lib/api/mutations.ts | 185 ++++---------- webapp/src/lib/api/queries.ts | 49 ---- webapp/src/lib/utils/queries.utils.ts | 24 -- 36 files changed, 710 insertions(+), 1016 deletions(-) delete mode 100644 functions/src/endpoints/templates.endpoints.ts delete mode 100644 functions/src/utils/check-stage-progression.ts delete mode 100644 functions/src/utils/get-user-chat.ts delete mode 100644 functions/src/utils/prefill-leader-votes.ts delete mode 100644 functions/src/utils/replace-chat-uuid.ts delete mode 100644 functions/src/utils/type-aliases.ts create mode 100644 functions/src/validation/chats.validation.ts create mode 100644 functions/src/validation/experiments.validation.ts create mode 100644 functions/src/validation/items.validation.ts delete mode 100644 webapp/src/lib/api/queries.ts delete mode 100644 webapp/src/lib/utils/queries.utils.ts diff --git a/.firebaserc.example b/.firebaserc.example index 0717802f..a0f1161c 100644 --- a/.firebaserc.example +++ b/.firebaserc.example @@ -1,5 +1,5 @@ { "projects": { - "default": "your-project-id-here" + "default": "your-project-id" } } diff --git a/README.md b/README.md index 300fefba..328edfe5 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Create the configuration files for a default firebase project: ```bash cp .firebaserc.example .firebaserc cp webapp/src/lib/api/firebase-config.example.ts webapp/src/lib/api/firebase-config.ts +cp scripts/service-account.example.json scripts/service-account.json ``` This should be enough for local development with emulators. Before deploying to production, be sure to: diff --git a/firestore/firestore.rules b/firestore/firestore.rules index ce9c019d..7378d65e 100644 --- a/firestore/firestore.rules +++ b/firestore/firestore.rules @@ -2,6 +2,46 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { + // ***************************************************************************************** // + // VALIDATION FUNCTIONS // + // ***************************************************************************************** // + + function validateString(value) { + return value is string; + } + + function validateInt(value) { + return value is int + } + + function validateFloat(value) { + return value is int || value is float; + } + + function validateTimestamp(value) { + return value.keys().hasAll(['seconds', 'nanoseconds']) && + validateNumber(value.seconds) && + validateNumber(value.nanoseconds); + } + + // Validate the profile + function validateProfile(value) { + return value.keys().hasOnly(['pronouns', 'avatarUrl', 'name', 'acceptTosTimestamp']) && + (!value.pronouns || validateString(value.pronouns)) && + (!value.avatarUrl || validateString(value.avatarUrl)) && + (!value.name || validateString(value.name)) && + (!value.acceptTosTimestamp || validateTimestamp(value.acceptTosTimestamp)); + } + + // Validate the chat document data + function validateChat(value) { + return value.keys().hasOnly(['readyToEndChat']) && value.readyToEndChat == false; + } + + // ***************************************************************************************** // + // RULES // + // ***************************************************************************************** // + // Template rules (experimenter-only) match /templates/{documents=**} { allow read: if request.auth.token.role == 'experimenter'; @@ -27,9 +67,10 @@ service cloud.firestore { // Participant rules match /participants/{participantId} { + allow get: if true; // Public if you know the ID allow list: if request.auth.token.role == 'experimenter'; // Avoid leaking IDs (only experimenters can view them) - allow update: if true; // TODO: contentn validation on write + allow update: if validateProfile(request.resource.data); match /stages/{stageId} { allow read: if true; @@ -37,7 +78,8 @@ service cloud.firestore { } match /chats/{chatId} { - allow read, update: if true; // TODO: content validation on write + allow read: if true; + allow update: if validateChat(request.resource.data); match /messages/{messageId} { allow read: if true; diff --git a/functions/src/endpoints/experiments.endpoints.ts b/functions/src/endpoints/experiments.endpoints.ts index 6036cffd..7fac54cd 100644 --- a/functions/src/endpoints/experiments.endpoints.ts +++ b/functions/src/endpoints/experiments.endpoints.ts @@ -1,157 +1,87 @@ /** Endpoints for interactions with experiments */ +import { + ChatAnswer, + GroupChatStageConfig, + ParticipantProfile, + StageKind, + participantPublicId, +} from '@llm-mediation-experiments/utils'; +import { Value } from '@sinclair/typebox/value'; +import { Timestamp } from 'firebase-admin/firestore'; import * as functions from 'firebase-functions'; import { onCall } from 'firebase-functions/v2/https'; -import { v4 as uuidv4 } from 'uuid'; import { app } from '../app'; -import { ParticipantSeeder } from '../seeders/participants.seeder'; import { AuthGuard } from '../utils/auth-guard'; -import { getUserChatIds } from '../utils/get-user-chat'; -import { prefillLeaderVotes } from '../utils/prefill-leader-votes'; -import { replaceChatStagesUuid } from '../utils/replace-chat-uuid'; - -/** Fetch all experiments in database (not paginated) */ -export const experiments = onCall(async (request) => { - await AuthGuard.isExperimenter(request); - - const experiments = await app.firestore().collection('experiments').get(); - const data = experiments.docs.map((doc) => ({ uid: doc.id, ...doc.data() })); - return { data }; -}); - -/** Fetch a specific experiment's extended data (ie: the experiment and all of its associated users) */ -export const experiment = onCall(async (request) => { - const { experimentUid } = request.data; - - if (!experimentUid) { - throw new functions.https.HttpsError('invalid-argument', 'Missing experiment UID'); - } - - const experiment = await app.firestore().collection('experiments').doc(experimentUid).get(); - - if (!experiment.exists) { - throw new functions.https.HttpsError('not-found', 'Experiment not found'); - } - - const experimentData = experiment.data(); - - if (!experimentData) { - throw new functions.https.HttpsError('internal', 'Experiment data is missing'); - } - - const participants = await app - .firestore() - .collection('participants') - .where('experimentId', '==', experimentUid) - .get(); - - const data = { - ...experimentData, - uid: experiment.id, - participants: participants.docs.map((doc) => ({ - uid: doc.id, - ...doc.data(), - })), - }; - - return data; -}); - -export const deleteExperiment = onCall(async (request) => { - await AuthGuard.isExperimenter(request); - - const { experimentId } = request.data; - - if (!experimentId) { - throw new functions.https.HttpsError('invalid-argument', 'Missing experiment UID'); - } - - const experiment = await app.firestore().collection('experiments').doc(experimentId).get(); - - if (!experiment.exists) { - throw new functions.https.HttpsError('not-found', 'Experiment not found'); - } - - // Delete all participants associated with the experiment - const participants = await app - .firestore() - .collection('participants') - .where('experimentId', '==', experimentId) - .get(); - - const chatIds = getUserChatIds(participants.docs[0]); - - const batch = app.firestore().batch(); - batch.delete(experiment.ref); - participants.docs.forEach((doc) => batch.delete(doc.ref)); - - // Delete the chat stage sync documents & the progression document - batch.delete(app.firestore().doc(`participants_progressions/${experimentId}`)); - chatIds.forEach((chatId) => { - batch.delete(app.firestore().doc(`participants_ready_to_end_chat/${chatId}`)); - }); - - await batch.commit(); - - return { data: `Experiment of ID ${experimentId} was successfully deleted` }; -}); +import { ExperimentCreationData } from '../validation/experiments.validation'; +/** Generic endpoint to create either experiments or experiment templates */ export const createExperiment = onCall(async (request) => { await AuthGuard.isExperimenter(request); - let uid = ''; - // Extract data from the body - const { name, stageMap, numberOfParticipants } = request.data; - const date = new Date(); - - const chatIds = replaceChatStagesUuid(stageMap); // Assign a new UUID to each chat stage - const participantIds = Array.from({ length: numberOfParticipants }, () => uuidv4()); - prefillLeaderVotes(stageMap, participantIds); - - await app.firestore().runTransaction(async (transaction) => { - // Create the main parent experiment - const experiment = app.firestore().collection('experiments').doc(); - transaction.set(experiment, { - name, - date, - numberOfParticipants, - }); - uid = experiment.id; - - // Create all derived participants with their stages - const participants = ParticipantSeeder.createMany( - experiment.id, - stageMap, - numberOfParticipants, - ); - - const progressions: Record = {}; - const participantRefs: string[] = []; - - for (const [i, participant] of participants.entries()) { - const participantId = participantIds[i]; - const participantRef = app.firestore().collection('participants').doc(participantId); - participantRefs.push(participantRef.id); - progressions[participantRef.id] = participant.workingOnStageName; - transaction.set(participantRef, participant); - } - - // Create the progression data in a separate collection - const progressionRef = app.firestore().doc(`participants_progressions/${experiment.id}`); - transaction.set(progressionRef, { - experimentId: experiment.id, - progressions, + const { data } = request; + const numberOfParticipants = 3; + + if (Value.Check(ExperimentCreationData, data)) { + // Run in a transaction to ensure consistency + const document = app.firestore().collection(data.type).doc(); + + await app.firestore().runTransaction(async (transaction) => { + // Create the metadata document + transaction.set(document, { + ...data.metadata, + ...(data.type === 'experiments' + ? { + date: Timestamp.now(), + numberOfParticipants, + } + : {}), + }); + + // Create the stages + for (const stage of data.stages) { + transaction.set(document.collection('stages').doc(stage.name), stage); + } + + // Nothing more to do if this was a template + if (data.type === 'templates') return; + + // Extract chats in order to pre-create the participant chat documents + const chats: GroupChatStageConfig[] = data.stages.filter( + (stage): stage is GroupChatStageConfig => stage.kind === StageKind.GroupChat, + ); + const workingOnStageName = data.stages[0].name; + + // Create all participants + Array.from({ length: numberOfParticipants }).forEach((_, i) => { + const participant = document.collection('participants').doc(); + const participantData: ParticipantProfile = { + publicId: participantPublicId(i), + + workingOnStageName, + pronouns: null, + name: null, + avatarUrl: null, + acceptTosTimestamp: null, + }; + + // Create the participant document + transaction.set(participant, participantData); + + // Create the chat documents + chats.forEach((chat) => { + const chatData: ChatAnswer = { + participantPublicId: participantData.publicId, + readyToEndChat: false, + stageName: chat.name, + }; + transaction.set(participant.collection('chats').doc(chat.chatId), chatData); + }); + }); }); - for (const chatId of chatIds) { - const ref = app.firestore().doc(`participants_ready_to_end_chat/${chatId}`); - const readyToEndChat = participantRefs.reduce((acc, uid) => { - acc[uid] = false; - return acc; - }, {} as Record); - transaction.set(ref, { chatId, readyToEndChat, currentPair: 0 }); - } - }); + return { id: document.id }; + } - return { data: 'Experiment created successfully', uid }; + throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); }); diff --git a/functions/src/endpoints/messages.endpoints.ts b/functions/src/endpoints/messages.endpoints.ts index 02586ed8..779031df 100644 --- a/functions/src/endpoints/messages.endpoints.ts +++ b/functions/src/endpoints/messages.endpoints.ts @@ -3,68 +3,35 @@ import { Value } from '@sinclair/typebox/value'; import * as functions from 'firebase-functions'; import { onCall } from 'firebase-functions/v2/https'; import { app } from '../app'; -import { - DiscussItemsMessageMutationData, - MediatorMessageMutationData, - UserMessageMutationData, -} from '../validation/messages.validation'; +import { MessageData } from '../validation/messages.validation'; +import { MessageKind } from '@llm-mediation-experiments/utils'; +import { Timestamp } from 'firebase-admin/firestore'; import { AuthGuard } from '../utils/auth-guard'; -export const userMessage = onCall(async (request) => { +export const message = onCall(async (request) => { const { data } = request; - if (Value.Check(UserMessageMutationData, data)) { - // Build message data - const msgData = { - ...data, - messageType: 'userMessage', - timestamp: new Date(), - }; - - const ref = await app.firestore().collection('messages').add(msgData); - return { uid: ref.id }; - } - - throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); -}); - -export const discussItemsMessage = onCall(async (request) => { - await AuthGuard.isExperimenter(request); - - const { data } = request; - - if (Value.Check(DiscussItemsMessageMutationData, data)) { - // Build message data - const msgData = { - ...data, - messageType: 'discussItemsMessage', - timestamp: new Date(), - }; - - const ref = await app.firestore().collection('messages').add(msgData); - return { uid: ref.id }; - } - - throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); -}); - -export const mediatorMessage = onCall(async (request) => { - await AuthGuard.isExperimenter(request); - - // access authenticated user - const { data } = request; - - if (Value.Check(MediatorMessageMutationData, data)) { - // Build message data - const msgData = { - ...data, - messageType: 'mediatorMessage', - timestamp: new Date(), - }; - - const ref = await app.firestore().collection('messages').add(msgData); - return { uid: ref.id }; + if (Value.Check(MessageData, data)) { + // Validate authentication status for experimenter messages + if (data.message.kind !== MessageKind.UserMessage) await AuthGuard.isExperimenter(request); + + const timestamp = Timestamp.now(); + + const participants = await app + .firestore() + .collection(`experiments/${data.experimentId}/participants`) + .get(); + + // Create all messages in transaction + await app.firestore().runTransaction(async (transaction) => { + participants.docs.forEach((participant) => { + transaction.set(participant.ref.collection(`chats/${data.chatId}/messages`).doc(), { + ...data.message, + timestamp, + }); + }); + }); } throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); diff --git a/functions/src/endpoints/participants.endpoints.ts b/functions/src/endpoints/participants.endpoints.ts index a466fd63..26a0a756 100644 --- a/functions/src/endpoints/participants.endpoints.ts +++ b/functions/src/endpoints/participants.endpoints.ts @@ -1,169 +1,57 @@ /** Endpoints for interactions with participants */ +import { QuestionAnswer, StageKind, SurveyStageConfig } from '@llm-mediation-experiments/utils'; import { Value } from '@sinclair/typebox/value'; -import { Timestamp } from 'firebase-admin/firestore'; import * as functions from 'firebase-functions'; import { onCall } from 'firebase-functions/v2/https'; import { app } from '../app'; -import { checkStageProgression } from '../utils/check-stage-progression'; -import { getUserChat } from '../utils/get-user-chat'; -import { ProfileAndTOS } from '../validation/participants.validation'; -import { - GenericStageUpdate, - ToggleReadyToEndChat, - validateStageUpdateAndMerge, -} from '../validation/stages.validation'; +import { StageAnswerData } from '../validation/stages.validation'; -/** Fetch a specific participant */ -export const participant = onCall(async (request) => { - const { participantUid } = request.data; - - if (!participantUid) { - throw new functions.https.HttpsError('invalid-argument', 'Missing participant UID'); - } - - const participant = await app.firestore().collection('participants').doc(participantUid).get(); - - if (!participant.exists) { - throw new functions.https.HttpsError('not-found', 'Participant not found'); - } - - const data = { uid: participant.id, ...participant.data() }; - - return data; -}); - -/** Update the profile and terms of service acceptance date for a participant */ -export const updateProfileAndTOS = onCall(async (request) => { - const { uid, ...body } = request.data; - - if (!uid) { - throw new functions.https.HttpsError('invalid-argument', 'Missing participant UID'); - } - - const participant = await app.firestore().collection('participants').doc(uid).get(); - - if (!participant.exists) { - throw new functions.https.HttpsError('not-found', 'Participant not found'); - } - - // Validate the body data - if (Value.Check(ProfileAndTOS, body)) { - // Patch the data - await participant.ref.update(checkStageProgression(participant, body)); - return { uid: participant.id, ...body }; // Send back the data - } else { - throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); - } -}); - -/** Generic endpoint for stage update. */ +/** Generic endpoint for stage answering. */ export const updateStage = onCall(async (request) => { - const { uid, ...body } = request.data; - - if (!uid) { - throw new functions.https.HttpsError('invalid-argument', 'Missing participant UID'); - } - - // Validate the generic stage update data - if (Value.Check(GenericStageUpdate, body)) { - const participant = await app.firestore().collection('participants').doc(uid).get(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stageMap: Record = participant.data()?.stageMap; - - if (!stageMap || !stageMap[body.name]) { - throw new functions.https.HttpsError('not-found', 'Stage not found'); + const { data } = request; + + if (Value.Check(StageAnswerData, data)) { + const { experimentId, participantId, stageName, stage } = data; + + // Validation + let error = false; + switch (stage.kind) { + case StageKind.VoteForLeader: + if (participantId in stage.votes) error = true; + break; + case StageKind.TakeSurvey: + error = await validateSurveyAnswers(experimentId, stageName, stage.answers); + break; } - const stage = stageMap[body.name]; - const valid = validateStageUpdateAndMerge(stage, body.data); - - if (!valid) - throw new functions.https.HttpsError( - 'invalid-argument', - `Invalid stage kind for update : ${stage.kind}`, - ); - else { - // Patch the data - const { justFinishedStageName } = body; - await participant.ref.update( - checkStageProgression(participant, { justFinishedStageName, stageMap }), - ); - return { uid }; // Send back the uid for refetch - } - } - throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); -}); + if (error) throw new functions.https.HttpsError('invalid-argument', 'Invalid answers'); -/** Toggle On/Off ready state for given participant and chat */ -export const toggleReadyToEndChat = onCall(async (request) => { - const { participantId, ...body } = request.data; + const answerDoc = app + .firestore() + .doc(`experiments/${experimentId}/participants/${participantId}/stages/${stageName}`); - if (!participantId) { - throw new functions.https.HttpsError('invalid-argument', 'Missing participant UID'); + await answerDoc.set(data, { merge: true }); } - if (Value.Check(ToggleReadyToEndChat, body)) { - if (body.readyToEndChat === false) { - throw new functions.https.HttpsError( - 'invalid-argument', - 'Cannot set readyToEndChat to false. Only true is allowed.', - ); - } - - await app.firestore().runTransaction(async (transaction) => { - const doc = await transaction.get( - app.firestore().collection('participants_ready_to_end_chat').doc(body.chatId), - ); - - const data = doc.data(); - - if (!data) { - throw new functions.https.HttpsError('not-found', 'Chat sync document not found'); - } - - if (data.readyToEndChat[participantId] === true) { - throw new functions.https.HttpsError( - 'invalid-argument', - 'Participant is already ready to end chat', - ); - } - - data.readyToEndChat[participantId] = true; - - // If everyone is now ready for the next pair, increment the current pair and reset everyone to false. - if (Object.values(data.readyToEndChat).every((value) => value === true)) { - data.currentPair += 1; - Object.keys(data.readyToEndChat).forEach((key) => { - data.readyToEndChat[key] = false; - }); - - const stage = await getUserChat(transaction, participantId, body.chatId); + throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); +}); - if (!stage) { - throw new functions.https.HttpsError('not-found', 'Chat not found'); - } +/** Helper function to validate a survey stage's answers against its related config */ +const validateSurveyAnswers = async ( + experimentId: string, + stageName: string, + answers: Record, +): Promise => { + const configDoc = app.firestore().doc(`experiments/${experimentId}/stages/${stageName}`); + const data = (await configDoc.get()).data() as SurveyStageConfig | undefined; - if (stage.config.ratingsToDiscuss.length > data.currentPair) { - const { id1, id2 } = stage.config.ratingsToDiscuss[data.currentPair]; - const itemPair = { - item1: stage.config.items[id1], - item2: stage.config.items[id2], - }; - transaction.set(app.firestore().collection('messages').doc(), { - chatId: body.chatId, - messageType: 'discussItemsMessage', - text: 'Discuss about this pair of items.', - itemPair, - timestamp: Timestamp.now(), - }); - } - } + if (!data) return false; - transaction.set(doc.ref, data); - }); - return { uid: participantId }; + for (const answer of Object.values(answers)) { + const config = data.questions[answer.id]; + if (!config || config.kind !== answer.kind) return false; } - throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); -}); + return true; +}; diff --git a/functions/src/endpoints/templates.endpoints.ts b/functions/src/endpoints/templates.endpoints.ts deleted file mode 100644 index 0eb6e527..00000000 --- a/functions/src/endpoints/templates.endpoints.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** Endpoints for interactions with experiments */ - -import { onCall } from 'firebase-functions/v2/https'; -import { app } from '../app'; -import { AuthGuard } from '../utils/auth-guard'; - -/** Fetch all templates (not paginated) */ -export const templates = onCall(async (request) => { - await AuthGuard.isExperimenter(request); - - const templates = await app.firestore().collection('templates').get(); - const data = templates.docs.map((doc) => ({ id: doc.id, ...doc.data() })); - return { data }; -}); - -/** Create an experiment template */ -export const createTemplate = onCall(async (request) => { - await AuthGuard.isExperimenter(request); - - // Extract data from the body - const { name, stageMap } = request.data; - - const template = app.firestore().collection('templates').doc(); - await app.firestore().runTransaction(async (transaction) => { - transaction.set(template, { - name, - stageMap, - }); - }); - - return { data: 'Template created successfully', uid: template.id }; -}); diff --git a/functions/src/triggers/chats.triggers.ts b/functions/src/triggers/chats.triggers.ts index 0e28f3ed..0fca438f 100644 --- a/functions/src/triggers/chats.triggers.ts +++ b/functions/src/triggers/chats.triggers.ts @@ -1,4 +1,4 @@ -import { ChatAnswer } from '@llm-mediation-experiments/utils'; +import { ChatAnswer, ChatKind } from '@llm-mediation-experiments/utils'; import { onDocumentWritten } from 'firebase-functions/v2/firestore'; import { app } from '../app'; @@ -10,13 +10,27 @@ export const publishParticipantReadyToEndChat = onDocumentWritten( if (!data) return; const { participantPublicId, readyToEndChat, stageName } = data; + const { experimentId } = event.params; const publicChatData = app .firestore() - .doc(`experiments/${event.params.experimentId}/publicStageData/${stageName}`); + .doc(`experiments/${experimentId}/publicStageData/${stageName}`); - publicChatData.update({ + await publicChatData.update({ [`readyToEndChat.${participantPublicId}`]: readyToEndChat, }); + + // Check whether all participants are ready to end the chat + // If the chat is a chat about items, increment the current item index + const docData = (await publicChatData.get()).data(); + + if (docData && Object.values(docData['readyToEndChat']).every((bool) => !bool)) { + // Everyone is ready to end the chat + if (docData['chatData'].kind === ChatKind.ChatAboutItems) { + // Increment the current item index + const current = docData['chatData'].currentRatingIndex; + await publicChatData.update({ [`chatData.currentRatingIndex`]: current + 1 }); + } + } }, ); diff --git a/functions/src/triggers/stages.triggers.ts b/functions/src/triggers/stages.triggers.ts index 7c388bab..ace65998 100644 --- a/functions/src/triggers/stages.triggers.ts +++ b/functions/src/triggers/stages.triggers.ts @@ -2,8 +2,12 @@ import { ChatKind, PublicChatData, PublicStageData, + StageAnswer, StageConfig, StageKind, + VoteForLeaderStagePublicData, + allVoteScores, + chooseLeader, } from '@llm-mediation-experiments/utils'; import { onDocumentWritten } from 'firebase-functions/v2/firestore'; import { app } from '../app'; @@ -57,5 +61,41 @@ export const initializePublicStageData = onDocumentWritten( }, ); -// TODO: publish stage data when a user votes or chats -// for VoteForLeader stages, also decide on the current leader after each incremental vote. +/** When a participant updates stage answers, publish the answers to */ +export const publishStageData = onDocumentWritten( + 'experiment/{experimentId}/participants/{participantId}/stages/{stageName}', + async (event) => { + const data = event.data?.after.data() as StageAnswer | undefined; + if (!data) return; + + const { experimentId, participantId, stageName } = event.params; + + switch (data.kind) { + case StageKind.VoteForLeader: + // Read the document 1st to avoid 2 writes + const publicDoc = await app + .firestore() + .doc(`experiments/${experimentId}/publicStageData/${stageName}`) + .get(); + const publicData = publicDoc.data() as VoteForLeaderStagePublicData; + + // Compute the updated votes + const newVotes = publicData.participantvotes; + newVotes[participantId] = data.votes; + + // Compute the new leader with these votes + const currentLeader = chooseLeader(allVoteScores(newVotes)); + + // Update the public data + await publicDoc.ref.update({ + participantvotes: newVotes, + currentLeader, + }); + + break; + case StageKind.TakeSurvey: + // Nothing to publish + break; + } + }, +); diff --git a/functions/src/utils/check-stage-progression.ts b/functions/src/utils/check-stage-progression.ts deleted file mode 100644 index 420112ea..00000000 --- a/functions/src/utils/check-stage-progression.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { app } from '../app'; -import { Progression } from '../validation/participants.validation'; -import { Document } from './type-aliases'; - -/** - * Checks if a participant's progress needs to be updated. Returns the original body data as well. - * - * Internally, if the stage needs to be changed, it will update the corresponding `participants_progressions` - * document entry in Firestore. - * - * @param participant - The participant document that might need to be updated - * @param body - The body of the request (potentially containing a new completed stage) - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const checkStageProgression = (participant: Document, body: T) => { - // Validate the body data - if (body.justFinishedStageName) { - const data = participant.data(); - if (!data) return body; - - const { justFinishedStageName, ...rest } = body; - if (data.completedStageNames.includes(justFinishedStageName)) return {}; - - // Rebuild the `completedStageNames` -> `workingOnStageName` -> `futureStageNames` sequence - const index = data.futureStageNames.indexOf(justFinishedStageName); - const completedStageNames = [ - ...data.completedStageNames, - data.workingOnStageName, - ...data.futureStageNames.slice(0, index + 1), - ]; - const futureStageNames = data.futureStageNames.slice(index + 2); - const workingOnStageName = - data.futureStageNames[0] ?? completedStageNames[completedStageNames.length - 1]; - - // Update the `participants_progressions` document - updateParticipantProgression(data.experimentId, participant.id, workingOnStageName); - - return { - ...rest, - completedStageNames, - futureStageNames, - workingOnStageName, - }; - } - - delete body.justFinishedStageName; // Just in case it is defined with `undefined` as a value - return body; -}; - -const updateParticipantProgression = ( - experimentId: string, - participantId: string, - workingOnStageName: string, -) => { - // Update the `participants_progressions` document - const progressionRef = app.firestore().doc(`participants_progressions/${experimentId}`); - - progressionRef.update({ - [`progressions.${participantId}`]: workingOnStageName, - }); -}; diff --git a/functions/src/utils/get-user-chat.ts b/functions/src/utils/get-user-chat.ts deleted file mode 100644 index 643249df..00000000 --- a/functions/src/utils/get-user-chat.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { app } from '../app'; -import { StageKind } from '../validation/stages.validation'; -import { Document } from './type-aliases'; - -/** Get data for a chat stage from a participant */ -export const getUserChat = async ( - transaction: FirebaseFirestore.Transaction, - participantId: string, - chatId: string, -): Promise => { - const participant = await transaction.get( - app.firestore().collection('participants').doc(participantId), - ); - - const stageMap: Record | undefined = participant.data()?.stageMap; - - if (!participant.exists || !stageMap) { - return null; - } - - for (const stage of Object.values(stageMap)) { - if (stage.kind === StageKind.GroupChat && stage.config.chatId === chatId) { - return stage; - } - } - - return null; -}; - -/** Extract all chat ids from a participant data */ -export const getUserChatIds = (participant: Document) => { - const chatIds: string[] = []; - - const stageMap: Record | undefined = participant.data()?.stageMap; - - if (!stageMap) { - return chatIds; - } - - for (const stage of Object.values(stageMap)) { - if (stage.kind === StageKind.GroupChat) { - chatIds.push(stage.config.chatId); - } - } - - return chatIds; -}; diff --git a/functions/src/utils/prefill-leader-votes.ts b/functions/src/utils/prefill-leader-votes.ts deleted file mode 100644 index 9b6fe6aa..00000000 --- a/functions/src/utils/prefill-leader-votes.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Prefill with not-rated votes the leader votes for all participants. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const prefillLeaderVotes = (stages: Record, participantUids: string[]) => { - const defaultVotes: Record = {}; - participantUids.forEach((uid) => { - defaultVotes[uid] = 'not-rated'; - }); - - Object.keys(stages).forEach((uuid) => { - if (stages[uuid].kind === 'voteForLeader') { - stages[uuid].config = { votes: defaultVotes }; - } - }); -}; diff --git a/functions/src/utils/replace-chat-uuid.ts b/functions/src/utils/replace-chat-uuid.ts deleted file mode 100644 index 60dc0f0c..00000000 --- a/functions/src/utils/replace-chat-uuid.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { v4 } from 'uuid'; - -/** Replaces all chatIds with new ones in order to create a brand new experiment. - * Returns the list of all new chat ids. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const replaceChatStagesUuid = (stages: Record) => { - const stageUuids = Object.keys(stages); - const chatUuids: string[] = []; - - stageUuids.forEach((uuid) => { - if (stages[uuid].kind === 'groupChat') { - const chatId = v4(); - stages[uuid].config.chatId = chatId; - chatUuids.push(chatId); - } - }); - - return chatUuids; -}; diff --git a/functions/src/utils/type-aliases.ts b/functions/src/utils/type-aliases.ts deleted file mode 100644 index ad83de61..00000000 --- a/functions/src/utils/type-aliases.ts +++ /dev/null @@ -1 +0,0 @@ -export type Document = FirebaseFirestore.DocumentSnapshot; diff --git a/functions/src/validation/chats.validation.ts b/functions/src/validation/chats.validation.ts new file mode 100644 index 00000000..315ca3bf --- /dev/null +++ b/functions/src/validation/chats.validation.ts @@ -0,0 +1,23 @@ +import { ChatKind } from '@llm-mediation-experiments/utils'; +import { Type } from '@sinclair/typebox'; + +/** Shorthand for strict TypeBox object validation */ +const strict = { additionalProperties: false } as const; + +/** Chat about items config */ +export const ChatAboutItemsConfigData = Type.Object( + { + kind: Type.Literal(ChatKind.ChatAboutItems), + ratingsToDiscuss: Type.Array( + Type.Object( + { + item1: Type.String({ minLength: 1 }), + item2: Type.String({ minLength: 1 }), + }, + strict, + ), + { minItems: 1 }, + ), + }, + strict, +); diff --git a/functions/src/validation/experiments.validation.ts b/functions/src/validation/experiments.validation.ts new file mode 100644 index 00000000..7700fc26 --- /dev/null +++ b/functions/src/validation/experiments.validation.ts @@ -0,0 +1,47 @@ +import { Type, type Static } from '@sinclair/typebox'; +import { + GroupChatStageConfigData, + ProfileStageConfigData, + RevealVotedConfigData, + SurveyStageConfigData, + TOSAndProfileConfigData, + TermsOfServiceConfigData, + VoteForLeaderConfigData, +} from './stages.validation'; + +/** Shorthand for strict TypeBox object validation */ +const strict = { additionalProperties: false } as const; + +/** + * Generic experiment or template creation data + */ +export const ExperimentCreationData = Type.Object( + { + // Discriminate between experiment and template + type: Type.Union([Type.Literal('experiments'), Type.Literal('templates')]), + + // Experiment / Template metadata + metadata: Type.Object( + { + name: Type.String({ minLength: 1 }), + }, + strict, + ), + + // Stage config data + stages: Type.Array( + Type.Union([ + TermsOfServiceConfigData, + ProfileStageConfigData, + TOSAndProfileConfigData, + SurveyStageConfigData, + GroupChatStageConfigData, + VoteForLeaderConfigData, + RevealVotedConfigData, + ]), + ), + }, + strict, +); + +export type ExperimentCreationData = Static; diff --git a/functions/src/validation/items.validation.ts b/functions/src/validation/items.validation.ts new file mode 100644 index 00000000..82481687 --- /dev/null +++ b/functions/src/validation/items.validation.ts @@ -0,0 +1,4 @@ +import { ITEM_NAMES } from '@llm-mediation-experiments/utils'; +import { Type } from '@sinclair/typebox'; + +export const ItemData = Type.Union(ITEM_NAMES.map((item) => Type.Literal(item))); diff --git a/functions/src/validation/messages.validation.ts b/functions/src/validation/messages.validation.ts index 60b0b0e0..64cdfdd9 100644 --- a/functions/src/validation/messages.validation.ts +++ b/functions/src/validation/messages.validation.ts @@ -1,33 +1,48 @@ +import { MessageKind } from '@llm-mediation-experiments/utils'; import { Type, type Static } from '@sinclair/typebox'; -export const UserMessageMutationData = Type.Object({ - chatId: Type.String({ minLength: 1 }), - text: Type.String({ minLength: 1 }), - fromUserId: Type.String({ minLength: 1 }), -}); +/** Shorthand for strict TypeBox object validation */ +const strict = { additionalProperties: false } as const; -export const DiscussItemsMessageMutationData = Type.Object({ - chatId: Type.String({ minLength: 1 }), - text: Type.String({ minLength: 1 }), - itemPair: Type.Object({ - item1: Type.Object({ - name: Type.String({ minLength: 1 }), - imageUrl: Type.String({ minLength: 1 }), - }), - item2: Type.Object({ - name: Type.String({ minLength: 1 }), - imageUrl: Type.String({ minLength: 1 }), - }), - }), -}); +/** Message sent by an user */ +export const UserMessageData = Type.Object( + { + kind: Type.Literal(MessageKind.UserMessage), + text: Type.String({ minLength: 1 }), + fromPublicParticipantId: Type.String({ minLength: 1 }), + }, + strict, +); -export const MediatorMessageMutationData = Type.Object({ - chatId: Type.String({ minLength: 1 }), - text: Type.String({ minLength: 1 }), -}); +/** Message sent by an experimenter to discuss about items */ +export const DiscussItemsMessageData = Type.Object( + { + kind: Type.Literal(MessageKind.DiscussItemsMessage), + text: Type.String({ minLength: 1 }), + itemPair: Type.Object({ + item1: Type.String({ minLength: 1 }), + item2: Type.String({ minLength: 1 }), + }), + }, + strict, +); -export type UserMessageMutationData = Static; +/** Message send by a mediator */ +export const MediatorMessageData = Type.Object( + { + kind: Type.Literal(MessageKind.MediatorMessage), + text: Type.String({ minLength: 1 }), + }, + strict, +); -export type DiscussItemsMessageMutationData = Static; +export const MessageData = Type.Object( + { + experimentId: Type.String({ minLength: 1 }), + chatId: Type.String({ minLength: 1 }), + message: Type.Union([UserMessageData, DiscussItemsMessageData, MediatorMessageData]), + }, + strict, +); -export type MediatorMessageMutationData = Static; +export type MessageData = Static; diff --git a/functions/src/validation/questions.validation.ts b/functions/src/validation/questions.validation.ts index 3635451a..f5ff9da3 100644 --- a/functions/src/validation/questions.validation.ts +++ b/functions/src/validation/questions.validation.ts @@ -1,103 +1,99 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/** Validation for the questions */ +import { SurveyQuestionKind } from '@llm-mediation-experiments/utils'; +import { Type } from '@sinclair/typebox'; +import { ItemData } from './items.validation'; -import { Type, type Static } from '@sinclair/typebox'; -import { Value } from '@sinclair/typebox/value'; - -// Copied from questions.types.ts -export enum SurveyQuestionKind { - Text = 'TextQuestion', - Check = 'CheckQuestion', - Rating = 'RatingQuestion', - Scale = 'ScaleQuestion', -} +/** Shorthand for strict TypeBox object validation */ +const strict = { additionalProperties: false } as const; // ********************************************************************************************* // -// DEFINITIONS // +// CONFIGS // // ********************************************************************************************* // -// Text Question - -export const TextQuestionUpdate = Type.Object( +/** Text question config */ +export const TextQuestionConfigData = Type.Object( { - answerText: Type.String(), + kind: Type.Literal(SurveyQuestionKind.Text), + id: Type.Number(), + questionText: Type.String({ minLength: 1 }), }, - { additionalProperties: false }, + strict, ); -export type TextQuestionUpdate = Static; - -// Check Question - -export const CheckQuestionUpdate = Type.Object( +/** Check question config */ +export const CheckQuestionConfigData = Type.Object( { - checkMark: Type.Boolean(), + kind: Type.Literal(SurveyQuestionKind.Check), + id: Type.Number(), + questionText: Type.String({ minLength: 1 }), }, - { additionalProperties: false }, + strict, ); -export type CheckQuestionUpdate = Static; - -// Rating Question - -export const RatingQuestionUpdate = Type.Object( +/** Rating question config */ +export const RatingQuestionConfigData = Type.Object( { - choice: Type.String(), - confidence: Type.Number({ minimum: 0, maximum: 1 }), + kind: Type.Literal(SurveyQuestionKind.Rating), + id: Type.Number(), + questionText: Type.String({ minLength: 1 }), + item1: ItemData, + item2: ItemData, }, - { additionalProperties: false }, + strict, ); -export type RatingQuestionUpdate = Static; - -// Scale Question - -export const ScaleQuestionUpdate = Type.Object( +/** Scale question config */ +export const ScaleQuestionConfigData = Type.Object( { - score: Type.Number({ minimum: 0, maximum: 10 }), + kind: Type.Literal(SurveyQuestionKind.Scale), + id: Type.Number(), + questionText: Type.String({ minLength: 1 }), + upperBound: Type.String({ minLength: 1 }), + lowerBound: Type.String({ minLength: 1 }), }, - { additionalProperties: false }, + strict, ); -export type ScaleQuestionUpdate = Static; - // ********************************************************************************************* // -// UTILS // +// ANSWERS // // ********************************************************************************************* // -/** Merge incoming update with the question data in place. - * - * @param question Existing question data from database - * @param data Incoming update data from the request - * @returns true if the update is valid and the merge was successful, false otherwise - */ -export const validateQuestionUpdateAndMerge = (question: any, data: any): boolean => { - let valid = false; - - switch (question.kind as SurveyQuestionKind) { - case SurveyQuestionKind.Text: - valid = Value.Check(TextQuestionUpdate, data); - break; - case SurveyQuestionKind.Check: - valid = Value.Check(CheckQuestionUpdate, data); - break; - - case SurveyQuestionKind.Rating: - valid = Value.Check(RatingQuestionUpdate, data); - break; - - case SurveyQuestionKind.Scale: - valid = Value.Check(ScaleQuestionUpdate, data); - break; - - default: - valid = false; - } +/** Text question answer data */ +export const TextQuestionAnswerData = Type.Object( + { + kind: Type.Literal(SurveyQuestionKind.Text), + id: Type.Number(), + answerText: Type.String({ minLength: 1 }), + }, + strict, +); - if (!valid) return false; +/** Check question answer data */ +export const CheckQuestionAnswerData = Type.Object( + { + kind: Type.Literal(SurveyQuestionKind.Check), + id: Type.Number(), + checkMark: Type.Boolean(), + }, + strict, +); - // Merge the data in place - Object.assign(question, data); +/** Rating question answer data */ +export const RatingQuestionAnswerData = Type.Object( + { + kind: Type.Literal(SurveyQuestionKind.Rating), + id: Type.Number(), + choice: ItemData, + confidence: Type.Number({ minimum: 0, maximum: 1 }), + }, + strict, +); - return true; -}; +/** Scale question answer data */ +export const ScaleQuestionAnswerData = Type.Object( + { + kind: Type.Literal(SurveyQuestionKind.Scale), + id: Type.Number(), + score: Type.Number({ minimum: 0, maximum: 10 }), + }, + strict, +); diff --git a/functions/src/validation/stages.validation.ts b/functions/src/validation/stages.validation.ts index 54abac24..7f1a5fd2 100644 --- a/functions/src/validation/stages.validation.ts +++ b/functions/src/validation/stages.validation.ts @@ -1,122 +1,147 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/** Validation for stage updates */ - +import { StageKind, Vote } from '@llm-mediation-experiments/utils'; import { Type, type Static } from '@sinclair/typebox'; -import { Value } from '@sinclair/typebox/value'; -import { PROGRESSION } from './participants.validation'; -import { validateQuestionUpdateAndMerge } from './questions.validation'; - -// Generic definition +import { ChatAboutItemsConfigData } from './chats.validation'; +import { + CheckQuestionAnswerData, + CheckQuestionConfigData, + RatingQuestionAnswerData, + RatingQuestionConfigData, + ScaleQuestionAnswerData, + ScaleQuestionConfigData, + TextQuestionAnswerData, + TextQuestionConfigData, +} from './questions.validation'; + +/** Shorthand for strict TypeBox object validation */ +const strict = { additionalProperties: false } as const; -// Copied from stages.types.ts -export enum StageKind { - AcceptTosAndSetProfile = 'acceptTosAndSetProfile', - GroupChat = 'groupChat', - VoteForLeader = 'voteForLeader', - RevealVoted = 'leaderReveal', - TakeSurvey = 'takeSurvey', - // RankItems = 'rankItems', -} +// ********************************************************************************************* // +// CONFIGS // +// ********************************************************************************************* // -export const GenericStageUpdate = Type.Object( +/** Terms of service stage config */ +export const TermsOfServiceConfigData = Type.Object( { - name: Type.String(), // Stage name - data: Type.Any(), // Data to update - ...PROGRESSION, + kind: Type.Literal(StageKind.TermsOfService), + name: Type.String({ minLength: 1 }), + tosLines: Type.Array(Type.String({ minLength: 1 })), }, - { additionalProperties: false }, + strict, ); -export type GenericStageUpdate = Static; - -export const ToggleReadyToEndChat = Type.Object( +/** Profile stage config */ +export const ProfileStageConfigData = Type.Object( { - readyToEndChat: Type.Boolean(), - chatId: Type.String(), + kind: Type.Literal(StageKind.Profile), + name: Type.String({ minLength: 1 }), }, - { additionalProperties: false }, + strict, ); -export type ToggleReadyToEndChat = Static; - -// ********************************************************************************************* // -// DEFINITIONS // -// ********************************************************************************************* // - -const SurveyUpdate = Type.Object({ - questions: Type.Array(Type.Any()), -}); - -const ChatUpdate = Type.Object({ - readyToEndChat: Type.Boolean(), -}); +/** Accept TOS and set profile stage config */ +export const TOSAndProfileConfigData = Type.Object( + { + kind: Type.Literal(StageKind.AcceptTosAndSetProfile), + name: Type.String({ minLength: 1 }), + tosLines: Type.Array(Type.String({ minLength: 1 })), + }, + strict, +); -const VoteUpdate = Type.Record(Type.String(), Type.String()); +/** Survey stage config */ +export const SurveyStageConfigData = Type.Object( + { + kind: Type.Literal(StageKind.TakeSurvey), + name: Type.String({ minLength: 1 }), + questions: Type.Array( + Type.Union([ + TextQuestionConfigData, + CheckQuestionConfigData, + RatingQuestionConfigData, + ScaleQuestionConfigData, + ]), + ), + }, + strict, +); -type SurveyUpdate = Static; +/** Group chat stage config */ +export const GroupChatStageConfigData = Type.Object( + { + kind: Type.Literal(StageKind.GroupChat), + name: Type.String({ minLength: 1 }), + chatId: Type.String({ minLength: 1 }), + chatConfig: Type.Union([ChatAboutItemsConfigData]), + }, + strict, +); -type ChatUpdate = Static; +/** Vote for leader stage config */ +export const VoteForLeaderConfigData = Type.Object( + { + kind: Type.Literal(StageKind.VoteForLeader), + name: Type.String({ minLength: 1 }), + }, + strict, +); -type VoteUpdate = Static; +/** Reveal leader after vote stage config */ +export const RevealVotedConfigData = Type.Object( + { + kind: Type.Literal(StageKind.RevealVoted), + name: Type.String({ minLength: 1 }), + pendingVoteStageName: Type.String({ minLength: 1 }), + }, + strict, +); // ********************************************************************************************* // -// UTILS // +// ANSWERS // // ********************************************************************************************* // -/** Merges incoming update with the stage data in place. - * - * @param stage Existing stage data from database - * @param data Incoming update data from the request - * @returns true if the update is valid and the merge was successful, false otherwise - */ -export const validateStageUpdateAndMerge = (stage: any, data: any): boolean => { - switch (stage.kind as StageKind) { - case StageKind.TakeSurvey: - return validateSurveyUpdateAndMerge(stage, data); - - case StageKind.GroupChat: - return validateChatUpdateAndMerge(stage, data); - - case StageKind.VoteForLeader: - return validateVoteUpdateAndMerge(stage, data); - - case StageKind.RevealVoted: - return validateLeaderRevealAndMerge(stage, data); - default: - return false; - } -}; - -const validateSurveyUpdateAndMerge = (stage: any, data: any): boolean => { - if (Value.Check(SurveyUpdate, data)) { - data.questions.forEach((questionUpdate, index) => { - validateQuestionUpdateAndMerge(stage.config.questions[index], questionUpdate); - }); - - return true; - } - return false; -}; +/** Survey stage answer data */ +export const SurveyStageAnswerData = Type.Object( + { + kind: Type.Literal(StageKind.TakeSurvey), + answers: Type.Record( + Type.Number({ minimum: 0 }), + Type.Union([ + TextQuestionAnswerData, + CheckQuestionAnswerData, + RatingQuestionAnswerData, + ScaleQuestionAnswerData, + ]), + ), + }, + strict, +); -const validateChatUpdateAndMerge = (stage: any, data: any): boolean => { - if (Value.Check(ChatUpdate, data)) { - stage.config.readyToEndChat = true; - return true; - } - return false; -}; +/** Vote for leader stage answer data */ +export const VoteForLeaderStageAnswerData = Type.Object( + { + kind: Type.Literal(StageKind.VoteForLeader), + votes: Type.Record( + Type.String({ minLength: 1 }), + Type.Union([ + Type.Literal(Vote.Positive), + Type.Literal(Vote.Neutral), + Type.Literal(Vote.Negative), + Type.Literal(Vote.NotRated), + ]), + ), + }, + strict, +); -const validateVoteUpdateAndMerge = (stage: any, data: any): boolean => { - if (Value.Check(VoteUpdate, data)) { - stage.config.votes = data; - return true; - } - return false; -}; +/** Stage answer data */ +export const StageAnswerData = Type.Object( + { + experimentId: Type.String({ minLength: 1 }), + participantId: Type.String({ minLength: 1 }), + stageName: Type.String({ minLength: 1 }), + stage: Type.Union([SurveyStageAnswerData, VoteForLeaderStageAnswerData]), + }, + strict, +); -const validateLeaderRevealAndMerge = (stage: any, data: any): boolean => { - if (data === null) { - return true; - } - return false; -}; +export type StageAnswerData = Static; diff --git a/scripts/src/seed-database.ts b/scripts/src/seed-database.ts index b02a50a8..e345e040 100644 --- a/scripts/src/seed-database.ts +++ b/scripts/src/seed-database.ts @@ -97,12 +97,14 @@ const DEFAULT_STAGES: Record = { kind: StageKind.TakeSurvey, questions: [ { + id: 0, kind: SurveyQuestionKind.Rating, questionText: 'Rate the items by how helpful they would be for survival.', item1: 'compas', item2: 'blanket', }, { + id: 1, kind: SurveyQuestionKind.Scale, questionText: 'Rate the how much you would like to be the group leader.', lowerBound: 'I would most definitely not like to be the leader (0/10)', @@ -130,6 +132,7 @@ const DEFAULT_STAGES: Record = { kind: StageKind.TakeSurvey, questions: [ { + id: 0, kind: SurveyQuestionKind.Scale, questionText: 'Rate the chat dicussion on a 1-10 scale.\nAlso indicate your overall feeling about the chat.', @@ -144,6 +147,7 @@ const DEFAULT_STAGES: Record = { kind: StageKind.TakeSurvey, questions: [ { + id: 0, kind: SurveyQuestionKind.Scale, questionText: 'Rate the how much you would like to be the group leader.', lowerBound: 'I would most definitely not like to be the leader (0/10)', @@ -162,6 +166,7 @@ const DEFAULT_STAGES: Record = { kind: StageKind.TakeSurvey, questions: [ { + id: 0, kind: SurveyQuestionKind.Rating, questionText: 'Please rating the following accoring to which is best for survival', item1: 'compas', @@ -181,6 +186,7 @@ const DEFAULT_STAGES: Record = { kind: StageKind.TakeSurvey, questions: [ { + id: 0, kind: SurveyQuestionKind.Scale, questionText: 'Rate how happy you were with the final outcome.\nAlso indicate your overall feeling about the experience.', diff --git a/utils/src/types/api.types.ts b/utils/src/types/api.types.ts index f2a5266b..58ac250f 100644 --- a/utils/src/types/api.types.ts +++ b/utils/src/types/api.types.ts @@ -30,14 +30,6 @@ export interface TemplateCreationData { stageMap: Record; } -export interface ProfileTOSData extends Progression { - uid: string; - name: string; - pronouns: string; - avatarUrl: string; - acceptTosTimestamp: string; -} - export interface ChatToggleUpdate { readyToEndChat: boolean; participantId: string; @@ -59,14 +51,8 @@ export type SurveyStageUpdate = GenericStageUpdate<{ questions: QuestionAnswer[]; }>; -export type ChatStageUpdate = GenericStageUpdate<{ - readyToEndChat: boolean; -}>; - export type LeaderVoteStageUpdate = GenericStageUpdate; -export type LeaderRevealStageUpdate = GenericStageUpdate; - // Helper for Timestamp (make it work between admin & sdk) // Packages firebase-admin/firestore and firebase/firestore use different Timestamp types // This type is a workaround to handle both types in the same codebase diff --git a/utils/src/types/chats.types.ts b/utils/src/types/chats.types.ts index 9bb5931b..71980d01 100644 --- a/utils/src/types/chats.types.ts +++ b/utils/src/types/chats.types.ts @@ -26,7 +26,7 @@ export type ChatConfig = ChatAboutItemsConfig; // ANSWERS // // ********************************************************************************************* // -/** Per-participant chat config */ +/** Per-participant chat config (stored in the participant chat document and not the chat stage answers) */ export interface ChatAnswer { readyToEndChat: boolean; diff --git a/utils/src/types/items.types.ts b/utils/src/types/items.types.ts index c086f540..a37fcbc3 100644 --- a/utils/src/types/items.types.ts +++ b/utils/src/types/items.types.ts @@ -17,7 +17,8 @@ export type ItemChoice = keyof ItemPair; // ITEMS // // ********************************************************************************************* // -export type ItemName = 'compas' | 'blanket' | 'lighter'; +export const ITEM_NAMES = ['blanket', 'compas', 'lighter'] as const; +export type ItemName = (typeof ITEM_NAMES)[number]; export const ITEMS: Record = { blanket: { name: 'blanket', diff --git a/utils/src/types/messages.types.ts b/utils/src/types/messages.types.ts index 0c5312a6..56dcdb78 100644 --- a/utils/src/types/messages.types.ts +++ b/utils/src/types/messages.types.ts @@ -4,7 +4,7 @@ import { uniqueId } from '../utils/algebraic.utils'; import { UnifiedTimestamp } from './api.types'; import { ItemPair } from './items.types'; -export enum MessageType { +export enum MessageKind { UserMessage = 'userMessage', DiscussItemsMessage = 'discussItemsMessage', MediatorMessage = 'mediatorMessage', @@ -12,26 +12,26 @@ export enum MessageType { export interface MessageBase { uid: string; - messageType: MessageType; + kind: MessageKind; timestamp: UnifiedTimestamp; text: string; } export interface UserMessage extends MessageBase { - messageType: MessageType.UserMessage; + kind: MessageKind.UserMessage; fromPublicParticipantId: string; } export interface DiscussItemsMessage extends MessageBase { - messageType: MessageType.DiscussItemsMessage; + kind: MessageKind.DiscussItemsMessage; itemPair: ItemPair; } export interface MediatorMessage extends MessageBase { - messageType: MessageType.MediatorMessage; + kind: MessageKind.MediatorMessage; } export type Message = UserMessage | DiscussItemsMessage | MediatorMessage; @@ -65,7 +65,7 @@ export interface MediatorMessageMutationData { export const getDefaultUserMessage = (timestamp: UnifiedTimestamp): UserMessage => ({ uid: uniqueId('message'), - messageType: MessageType.UserMessage, + kind: MessageKind.UserMessage, timestamp, fromPublicParticipantId: '', text: 'fakeMessage', @@ -73,7 +73,7 @@ export const getDefaultUserMessage = (timestamp: UnifiedTimestamp): UserMessage export const getDefaultMediatorMessage = (timestamp: UnifiedTimestamp): MediatorMessage => ({ uid: uniqueId('message'), - messageType: MessageType.MediatorMessage, + kind: MessageKind.MediatorMessage, timestamp, text: 'fakeMessage', }); diff --git a/utils/src/types/participants.types.ts b/utils/src/types/participants.types.ts index 0950ca26..72ad089d 100644 --- a/utils/src/types/participants.types.ts +++ b/utils/src/types/participants.types.ts @@ -4,14 +4,18 @@ import { UnifiedTimestamp } from './api.types'; -export interface ParticipantProfile { - publicId: string; // Public identifier for the participant inside an experiment - +/** Profile data that is modifiable by the participant */ +export interface ParticipantProfileBase { pronouns: string | null; avatarUrl: string | null; name: string | null; acceptTosTimestamp: UnifiedTimestamp | null; +} + +/** Full participant profile document data */ +export interface ParticipantProfile extends ParticipantProfileBase { + publicId: string; // Public identifier for the participant inside an experiment workingOnStageName: string; } diff --git a/utils/src/types/questions.types.ts b/utils/src/types/questions.types.ts index 05dd1fda..dd33fabb 100644 --- a/utils/src/types/questions.types.ts +++ b/utils/src/types/questions.types.ts @@ -15,6 +15,7 @@ export enum SurveyQuestionKind { interface BaseQuestionConfig { kind: SurveyQuestionKind; + id: number; questionText: string; } @@ -52,6 +53,7 @@ export type QuestionConfig = interface BaseQuestionAnswer { kind: SurveyQuestionKind; + id: number; } export interface TextQuestionAnswer extends BaseQuestionAnswer { @@ -70,13 +72,13 @@ export interface RatingQuestionAnswer extends BaseQuestionAnswer { kind: SurveyQuestionKind.Rating; choice: ItemName | null; - confidence: number | null; + confidence: number | null; // Confidence in the choice, from 0 to 1 } export interface ScaleQuestionAnswer extends BaseQuestionAnswer { kind: SurveyQuestionKind.Scale; - score: number | null; + score: number | null; // Score on a scale of 0 to 10 } export type QuestionAnswer = @@ -91,6 +93,7 @@ export type QuestionAnswer = export const getDefaultTextQuestion = (): TextQuestionConfig => { return { + id: 0, kind: SurveyQuestionKind.Text, questionText: '', }; @@ -98,6 +101,7 @@ export const getDefaultTextQuestion = (): TextQuestionConfig => { export const getDefaultCheckQuestion = (): CheckQuestionConfig => { return { + id: 0, kind: SurveyQuestionKind.Check, questionText: '', }; @@ -105,6 +109,7 @@ export const getDefaultCheckQuestion = (): CheckQuestionConfig => { export const getDefaultItemRatingsQuestion = (): RatingQuestionConfig => { return { + id: 0, kind: SurveyQuestionKind.Rating, questionText: '', item1: 'blanket', @@ -114,6 +119,7 @@ export const getDefaultItemRatingsQuestion = (): RatingQuestionConfig => { export const getDefaultScaleQuestion = (): ScaleQuestionConfig => { return { + id: 0, kind: SurveyQuestionKind.Scale, questionText: '', upperBound: '', diff --git a/utils/src/types/stages.types.ts b/utils/src/types/stages.types.ts index 59092bde..cfa96a87 100644 --- a/utils/src/types/stages.types.ts +++ b/utils/src/types/stages.types.ts @@ -53,7 +53,9 @@ export interface AcceptTosAndSetProfileStageConfig extends BaseStageConfig { export interface SurveyStageConfig extends BaseStageConfig { kind: StageKind.TakeSurvey; - questions: QuestionConfig[]; + // Store questions in a map to allow for easy access by id. + // The question id is actually its index as if this was an array. + questions: Record; } export interface GroupChatStageConfig extends BaseStageConfig { @@ -93,7 +95,7 @@ interface BaseStageAnswer { export interface SurveyStageAnswer extends BaseStageAnswer { kind: StageKind.TakeSurvey; - answers: QuestionAnswer[]; + answers: Record; } export interface VoteForLeaderStageAnswer extends BaseStageAnswer { diff --git a/utils/src/types/votes.types.ts b/utils/src/types/votes.types.ts index a35333a3..ad36d471 100644 --- a/utils/src/types/votes.types.ts +++ b/utils/src/types/votes.types.ts @@ -9,6 +9,54 @@ export enum Vote { export type Votes = Record; +// ********************************************************************************************* // +// UTILS // +// ********************************************************************************************* // + +/** Get the value of each vote */ +export const voteScore = (vote: Vote): number => { + switch (vote) { + case Vote.Positive: + return 1; + case Vote.Neutral: + return 0; + case Vote.Negative: + return -1; + case Vote.NotRated: + return 0; + } +}; + +/** Agregate the votes of all participants */ +export const allVoteScores = (allVotes: Record): Record => { + const scores: Record = {}; + + Object.values(allVotes).forEach((votes) => { + Object.entries(votes).forEach(([participantId, vote]) => { + scores[participantId] = (scores[participantId] || 0) + voteScore(vote); + }); + }); + return scores; +}; + +/** Choose the leader based on a record of scores */ +export const chooseLeader = (scores: Record): string => { + // Note: if there is a tie, we choose randomly + return Object.keys(scores).reduce((a, b) => winner(a, b, scores)); +}; + +/** Helper function to compute the winner */ +const winner = (a: string, b: string, scores: Record) => { + if (scores[a] > scores[b]) { + return a; + } else if (scores[a] < scores[b]) { + return b; + } + + // Tie, choose randomly + return Math.random() > 0.5 ? a : b; +}; + // ********************************************************************************************* // // DEFAULTS // // ********************************************************************************************* // diff --git a/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts b/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts index c9a5d5e2..a705e31e 100644 --- a/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts +++ b/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts @@ -11,9 +11,8 @@ import { StageKind, isOfKind, } from '@llm-mediation-experiments/utils'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; import { AppStateService } from 'src/app/services/app-state.service'; -import { deleteExperimentMutation } from 'src/lib/api/mutations'; +import { deleteExperiment } from 'src/lib/api/mutations'; import { ExperimentRepository } from 'src/lib/repositories/experiment.repository'; import { MediatorChatComponent } from '../mediator-chat/mediator-chat.component'; @@ -34,9 +33,6 @@ import { MediatorChatComponent } from '../mediator-chat/mediator-chat.component' styleUrl: './experiment-monitor.component.scss', }) export class ExperimentMonitorComponent { - // Experiment deletion mutation - rmExperiment = deleteExperimentMutation(injectQueryClient()); - public _experimentId: WritableSignal = signal(undefined); @Input() @@ -80,13 +76,13 @@ export class ExperimentMonitorComponent { ); } - deleteExperiment() { + deleteExperimentAndNavigate() { const experimentUid = this.experimentId(); if (experimentUid && confirm('⚠️ This will delete the experiment! Are you sure?')) { - this.rmExperiment.mutate(experimentUid); - - // Redirect to settings page. - this.router.navigate(['/experimenter', 'settings']); + deleteExperiment(experimentUid).then(() => { + // Redirect to settings page. + this.router.navigate(['/experimenter', 'settings']); + }); } } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts index ed764df0..2efc762b 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts @@ -16,6 +16,7 @@ import { ITEMS, ItemPair, StageKind, getDefaultItemPair } from '@llm-mediation-e import { AppStateService } from 'src/app/services/app-state.service'; import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; +import { markReadyToEndChat } from 'src/lib/api/mutations'; import { ChatRepository } from 'src/lib/repositories/chat.repository'; import { localStorageTimer, subscribeSignal } from 'src/lib/utils/angular.utils'; import { ChatDiscussItemsMessageComponent } from './chat-discuss-items-message/chat-discuss-items-message.component'; @@ -67,6 +68,8 @@ export class ExpChatComponent { participantId: this.participantService.participantId()!, }); + this.readyToEndChat = computed(() => this.chat?.chat()?.readyToEndChat ?? false); + // Initialize the current rating to discuss with the first available pair const { item1, item2 } = config.chatConfig.ratingsToDiscuss[0]; this.currentRatingsToDiscuss = signal({ item1, item2 }); @@ -77,7 +80,7 @@ export class ExpChatComponent { } public everyoneReachedTheChat: Signal; - public readyToEndChat = signal(false); + public readyToEndChat: Signal = signal(false); // Extracted stage data (needed ?) public currentRatingsToDiscuss: WritableSignal; @@ -116,17 +119,14 @@ export class ExpChatComponent { toggleEndChat() { if (this.readyToEndChat()) return; - // TODO: use new backend - // this.toggleMutation.mutate({ - // chatId: this.stage.config.chatId, - // participantId: this.participant.userData()!.uid, - // readyToEndChat: true, - // }); + + markReadyToEndChat( + this.participantService.experimentId()!, + this.participantService.participantId()!, + this.stage.config().chatId, + ); this.message.disable(); this.timer.remove(); } } - -// TODO: faire fonctionner le reste du html, puis go courses. -// ensuite yaura les petits "sous-messages" à voir. diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts index 8cb30127..b1bc2447 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts @@ -16,11 +16,10 @@ import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { ProfileTOSData, StageKind, UnifiedTimestamp } from '@llm-mediation-experiments/utils'; +import { StageKind, UnifiedTimestamp } from '@llm-mediation-experiments/utils'; import { Timestamp } from 'firebase/firestore'; import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; -import { updateProfileAndTOSMutation } from 'src/lib/api/mutations'; -import { MutationType } from 'src/lib/types/tanstack.types'; +import { updateTOSAndProfile } from 'src/lib/api/mutations'; enum Pronouns { HeHim = 'He/Him', @@ -60,12 +59,9 @@ export class ExpTosAndProfileComponent { http = inject(HttpClient); queryClient = injectQueryClient(); - profileMutation: MutationType; value = ''; // Custom pronouns input value - constructor(participantService: ParticipantService) { - this.profileMutation = updateProfileAndTOSMutation(this.queryClient); - + constructor(public participantService: ParticipantService) { // Refresh the form data when the participant profile changes effect(() => { const profile = participantService.participant()?.profile(); @@ -100,6 +96,12 @@ export class ExpTosAndProfileComponent { } nextStep() { - // TODO: refactor with new backend + updateTOSAndProfile( + this.participantService.experimentId()!, + this.participantService.participantId()!, + this.profileFormControl.value, + ); + + // TODO: naviguate to next stage on success, after editing "viewing stage", on success of it } } diff --git a/webapp/src/lib/api/callables.ts b/webapp/src/lib/api/callables.ts index 2619bda0..bbb52202 100644 --- a/webapp/src/lib/api/callables.ts +++ b/webapp/src/lib/api/callables.ts @@ -1,7 +1,6 @@ /** Firebase cloud function callables */ import { - ChatToggleUpdate, CreationResponse, DiscussItemsMessageMutationData, Experiment, @@ -10,7 +9,6 @@ import { GenericStageUpdate, MediatorMessageMutationData, ParticipantProfile, - ProfileTOSData, SimpleResponse, TemplateCreationData, UserMessageMutationData, @@ -59,19 +57,11 @@ export const participantCallable = data( httpsCallable<{ participantUid: string }, ParticipantProfile>(functions, 'participant'), ); -export const updateProfileAndTOSCallable = data( - httpsCallable(functions, 'updateProfileAndTOS'), -); - export const updateStageCallable = data( // eslint-disable-next-line @typescript-eslint/no-explicit-any httpsCallable, CreationResponse>(functions, 'updateStage'), ); -export const toggleReadyToEndChatCallable = data( - httpsCallable(functions, 'toggleReadyToEndChat'), -); - export const templatesCallable = data( httpsCallable>(functions, 'templates'), ); @@ -79,3 +69,5 @@ export const templatesCallable = data( export const createTemplateCallable = data( httpsCallable(functions, 'createTemplate'), ); + +// TODO : create callables for the 3 new cloud functions, and remove the declarations for the old ones diff --git a/webapp/src/lib/api/mutations.ts b/webapp/src/lib/api/mutations.ts index 29d231a5..603a55f7 100644 --- a/webapp/src/lib/api/mutations.ts +++ b/webapp/src/lib/api/mutations.ts @@ -1,156 +1,65 @@ -/** Tanstack angular mutations. +/** Tanstack angular mutations (not anymore, remove tanstack after this) */ -import { - ChatStageUpdate, - CreationResponse, - LeaderRevealStageUpdate, - LeaderVoteStageUpdate, - OnSuccess, - ProfileTOSData, - SurveyStageUpdate, -} from '@llm-mediation-experiments/utils'; -import { QueryClient, injectMutation } from '@tanstack/angular-query-experimental'; -import { - createExperimentCallable, - createTemplateCallable, - deleteExperimentCallable, - discussItemsMessageCallable, - mediatorMessageCallable, - toggleReadyToEndChatCallable, - updateProfileAndTOSCallable, - updateStageCallable, - userMessageCallable, -} from './callables'; +import { ParticipantProfileBase } from '@llm-mediation-experiments/utils'; -export const deleteExperimentMutation = (client: QueryClient) => - injectMutation(() => ({ - mutationFn: (experimentId: string) => deleteExperimentCallable({ experimentId }), - onSuccess: () => { - client.refetchQueries({ queryKey: ['experiments'] }); - }, - })); - -export const createExperimentMutation = ( - client: QueryClient, - onSuccess?: OnSuccess, -) => { - return injectMutation(() => ({ - mutationFn: createExperimentCallable, - onSuccess: (data) => { - client.refetchQueries({ queryKey: ['experiments'] }); - onSuccess?.(data); - }, - })); -}; - -export const createTemplateMutation = ( - client: QueryClient, - onSuccess?: OnSuccess, -) => { - return injectMutation(() => ({ - mutationFn: createTemplateCallable, - onSuccess: (data) => { - client.refetchQueries({ queryKey: ['templates'] }); - onSuccess?.(data); - }, - })); -}; +// TODO: put all these functions in the relevant repositories instead ! // ********************************************************************************************* // -// STAGE MUTATIONS // +// DELETE // // ********************************************************************************************* // -export const updateProfileAndTOSMutation = ( - client: QueryClient, - onSuccess?: OnSuccess, -) => { - return injectMutation(() => ({ - mutationFn: updateProfileAndTOSCallable, - onSuccess: (data) => { - client.refetchQueries({ queryKey: ['participant', data.uid] }); - onSuccess?.(data); - }, - })); -}; +import { deleteDoc, doc, updateDoc } from 'firebase/firestore'; +import { firestore } from './firebase'; -export const updateSurveyStageMutation = ( - client: QueryClient, - onSuccess?: OnSuccess<{ uid: string }>, -) => { - return injectMutation(() => ({ - mutationFn: (data: SurveyStageUpdate) => updateStageCallable(data), - onSuccess: (data) => { - client.refetchQueries({ queryKey: ['participant', data.uid] }); - onSuccess?.(data); - }, - })); -}; +/** Delete an experiment. + * @rights Experimenter + */ +export const deleteExperiment = (experimentId: string) => + deleteDoc(doc(firestore, 'experiments', experimentId)); -export const updateChatStageMutation = ( - client: QueryClient, - onSuccess?: OnSuccess<{ uid: string }>, -) => { - return injectMutation(() => ({ - mutationFn: (data: ChatStageUpdate) => updateStageCallable(data), - onSuccess: (data) => { - client.refetchQueries({ queryKey: ['participant', data.uid] }); - onSuccess?.(data); - }, - })); -}; +/** Delete a template. + * @rights Experimenter + */ +export const deleteTemplate = (templateId: string) => + deleteDoc(doc(firestore, 'templates', templateId)); -export const updateLeaderVoteStageMutation = ( - client: QueryClient, - onSuccess?: OnSuccess<{ uid: string }>, -) => { - return injectMutation(() => ({ - mutationFn: (data: LeaderVoteStageUpdate) => updateStageCallable(data), - onSuccess: (data) => { - client.refetchQueries({ queryKey: ['participant', data.uid] }); - onSuccess?.(data); - }, - })); -}; +// ********************************************************************************************* // +// CHAT // +// ********************************************************************************************* // -export const updateLeaderRevealStageMutation = ( - client: QueryClient, - onSuccess?: OnSuccess<{ uid: string }>, -) => { - return injectMutation(() => ({ - mutationFn: (data: LeaderRevealStageUpdate) => updateStageCallable(data), - onSuccess: (data) => { - client.refetchQueries({ queryKey: ['participant', data.uid] }); - onSuccess?.(data); +/** Mark the given participant as ready to end the chat, or to go to the next pair + * @rights Participant + */ +export const markReadyToEndChat = (experimentId: string, participantId: string, chatId: string) => + updateDoc( + doc(firestore, 'experiments', experimentId, 'participants', participantId, 'chats', chatId), + { + readyToEndChat: true, }, - })); -}; + ); // ********************************************************************************************* // -// MESSAGE MUTATIONS // +// PROFILE & TOS // // ********************************************************************************************* // -export const userMessageMutation = () => { - return injectMutation(() => ({ - mutationFn: userMessageCallable, - })); -}; - -export const discussItemMessageMutation = () => { - return injectMutation(() => ({ - mutationFn: discussItemsMessageCallable, - })); -}; +/** Update a participant's profile and acceptance of TOS. + * @rights Participant + */ +export const updateTOSAndProfile = ( + experimentId: string, + participantId: string, + data: Partial, +) => updateDoc(doc(firestore, 'experiments', experimentId, 'participants', participantId), data); -export const mediatorMessageMutation = () => { - return injectMutation(() => ({ - mutationFn: mediatorMessageCallable, - })); -}; +// ********************************************************************************************* // +// STAGES // +// ********************************************************************************************* // -// Chat toggle mutation -export const toggleChatMutation = () => { - return injectMutation(() => ({ - mutationFn: toggleReadyToEndChatCallable, - })); -}; +/** Update a participant's `workingOnStageName` + * @rights Participant + */ +export const workOnStage = (experimentId: string, participantId: string, stageName: string) => + updateDoc(doc(firestore, 'experiments', experimentId, 'participants', participantId), { + workingOnStageName: stageName, + }); diff --git a/webapp/src/lib/api/queries.ts b/webapp/src/lib/api/queries.ts deleted file mode 100644 index 0e96550c..00000000 --- a/webapp/src/lib/api/queries.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** Tanstack angular queries. - * They are defined here in order to make the query structure more apparent. - */ - -import { Signal } from '@angular/core'; -import { ExperimentExtended } from '@llm-mediation-experiments/utils'; -import { injectQuery } from '@tanstack/angular-query-experimental'; -import { - experimentCallable, - experimentsCallable, - participantCallable, - templatesCallable, -} from './callables'; - -/** Fetch all experiments stored in database (without pagination) */ -export const experimentsQuery = () => - injectQuery(() => ({ - queryKey: ['experiments'], - queryFn: () => experimentsCallable(), - })); - -/** Fetch data about a specific experiment (will fetch its participant's data too) */ -export const experimentQuery = (experimentId: Signal) => { - return injectQuery(() => ({ - queryKey: ['experiment', experimentId()], - queryFn: () => { - const experimentUid = experimentId(); - if (!experimentUid) return {} as ExperimentExtended; - return experimentCallable({ experimentUid }); - }, - disabled: experimentId() === null, - })); -}; - -/** Fetch all templates */ -export const templatesQuery = () => - injectQuery(() => ({ - queryKey: ['templates'], - queryFn: () => templatesCallable(), - })); - -/** Fetch a specific participant. Can be used to verify that a participant ID is valid */ -export const participantQuery = (participantUid?: string, isForAuth?: boolean) => - injectQuery(() => ({ - queryKey: ['participant', participantUid], - queryFn: () => participantCallable({ participantUid: participantUid! }), - disabled: participantUid === undefined, - retry: isForAuth ? 0 : undefined, - })); diff --git a/webapp/src/lib/utils/queries.utils.ts b/webapp/src/lib/utils/queries.utils.ts deleted file mode 100644 index 6b989b4e..00000000 --- a/webapp/src/lib/utils/queries.utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { toObservable } from '@angular/core/rxjs-interop'; -import { QueryType } from '../types/tanstack.types'; - -/** Given an Angular Tanstack Query, returns a promise that resolves to: - * - `true` if the query was successful - * - `false` if not. - * - * Used for the participant auth guards - */ -export const querySuccessPromise = (query: QueryType): Promise => { - const status = toObservable(query.status); - - return new Promise((resolve) => { - const subscription = status.subscribe((status) => { - if (status === 'success') { - resolve(true); - subscription.unsubscribe(); - } else if (status === 'error') { - resolve(false); - subscription.unsubscribe(); - } - }); - }); -}; From 3ed803f14e431b77ff7f927f28059f5258d27704 Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Thu, 16 May 2024 14:02:41 +0200 Subject: [PATCH 22/35] move validation to utils with peer dependency --- functions/package-lock.json | 8 ++++--- functions/package.json | 2 +- .../src/endpoints/experiments.endpoints.ts | 2 +- functions/src/endpoints/messages.endpoints.ts | 5 ++-- .../src/endpoints/participants.endpoints.ts | 8 +++++-- functions/src/validation/items.validation.ts | 4 ---- utils/package-lock.json | 7 ++++++ utils/package.json | 1 + utils/src/index.ts | 9 +++++++ utils/src/types/api.types.ts | 24 ------------------- utils/src/types/experiments.types.ts | 7 ------ .../src/validation/chats.validation.ts | 6 +++-- .../src/validation/experiments.validation.ts | 0 utils/src/validation/items.validation.ts | 5 ++++ .../src/validation/messages.validation.ts | 2 +- .../src/validation/participants.validation.ts | 0 .../src/validation/questions.validation.ts | 2 +- .../src/validation/stages.validation.ts | 3 ++- 18 files changed, 46 insertions(+), 49 deletions(-) delete mode 100644 functions/src/validation/items.validation.ts rename {functions => utils}/src/validation/chats.validation.ts (72%) rename {functions => utils}/src/validation/experiments.validation.ts (100%) create mode 100644 utils/src/validation/items.validation.ts rename {functions => utils}/src/validation/messages.validation.ts (95%) rename {functions => utils}/src/validation/participants.validation.ts (100%) rename {functions => utils}/src/validation/questions.validation.ts (97%) rename {functions => utils}/src/validation/stages.validation.ts (97%) diff --git a/functions/package-lock.json b/functions/package-lock.json index de451226..f19b9964 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -7,7 +7,7 @@ "name": "functions", "dependencies": { "@llm-mediation-experiments/utils": "file:../utils", - "@sinclair/typebox": "^0.32.20", + "@sinclair/typebox": "^0.32.30", "firebase-admin": "^12.1.0", "firebase-functions": "^5.0.0", "unique-names-generator": "^4.7.1", @@ -31,6 +31,7 @@ "name": "@llm-mediation-experiments/utils", "version": "1.0.0", "peerDependencies": { + "@sinclair/typebox": "^0.32.30", "firebase": "^10.11.1" } }, @@ -2332,8 +2333,9 @@ "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { - "version": "0.32.20", - "license": "MIT" + "version": "0.32.30", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.32.30.tgz", + "integrity": "sha512-IYK1H0k2sHVB2GjzBK2DXBErhex45GoLuPdgn8lNw5t0+5elIuhpixOMPobFyq6kE0AGIBa4+76Ph4enco0q2Q==" }, "node_modules/@sinonjs/commons": { "version": "3.0.1", diff --git a/functions/package.json b/functions/package.json index afc8feaa..7032b64d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -16,7 +16,7 @@ "main": "lib/index.js", "dependencies": { "@llm-mediation-experiments/utils": "file:../utils", - "@sinclair/typebox": "^0.32.20", + "@sinclair/typebox": "^0.32.30", "firebase-admin": "^12.1.0", "firebase-functions": "^5.0.0", "unique-names-generator": "^4.7.1", diff --git a/functions/src/endpoints/experiments.endpoints.ts b/functions/src/endpoints/experiments.endpoints.ts index 7fac54cd..a46e6800 100644 --- a/functions/src/endpoints/experiments.endpoints.ts +++ b/functions/src/endpoints/experiments.endpoints.ts @@ -2,6 +2,7 @@ import { ChatAnswer, + ExperimentCreationData, GroupChatStageConfig, ParticipantProfile, StageKind, @@ -13,7 +14,6 @@ import * as functions from 'firebase-functions'; import { onCall } from 'firebase-functions/v2/https'; import { app } from '../app'; import { AuthGuard } from '../utils/auth-guard'; -import { ExperimentCreationData } from '../validation/experiments.validation'; /** Generic endpoint to create either experiments or experiment templates */ export const createExperiment = onCall(async (request) => { diff --git a/functions/src/endpoints/messages.endpoints.ts b/functions/src/endpoints/messages.endpoints.ts index 779031df..2125e484 100644 --- a/functions/src/endpoints/messages.endpoints.ts +++ b/functions/src/endpoints/messages.endpoints.ts @@ -3,9 +3,8 @@ import { Value } from '@sinclair/typebox/value'; import * as functions from 'firebase-functions'; import { onCall } from 'firebase-functions/v2/https'; import { app } from '../app'; -import { MessageData } from '../validation/messages.validation'; -import { MessageKind } from '@llm-mediation-experiments/utils'; +import { MessageData, MessageKind } from '@llm-mediation-experiments/utils'; import { Timestamp } from 'firebase-admin/firestore'; import { AuthGuard } from '../utils/auth-guard'; @@ -32,6 +31,8 @@ export const message = onCall(async (request) => { }); }); }); + + return { data: 'success' }; } throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); diff --git a/functions/src/endpoints/participants.endpoints.ts b/functions/src/endpoints/participants.endpoints.ts index 26a0a756..f355aec7 100644 --- a/functions/src/endpoints/participants.endpoints.ts +++ b/functions/src/endpoints/participants.endpoints.ts @@ -1,11 +1,15 @@ /** Endpoints for interactions with participants */ -import { QuestionAnswer, StageKind, SurveyStageConfig } from '@llm-mediation-experiments/utils'; +import { + QuestionAnswer, + StageAnswerData, + StageKind, + SurveyStageConfig, +} from '@llm-mediation-experiments/utils'; import { Value } from '@sinclair/typebox/value'; import * as functions from 'firebase-functions'; import { onCall } from 'firebase-functions/v2/https'; import { app } from '../app'; -import { StageAnswerData } from '../validation/stages.validation'; /** Generic endpoint for stage answering. */ export const updateStage = onCall(async (request) => { diff --git a/functions/src/validation/items.validation.ts b/functions/src/validation/items.validation.ts deleted file mode 100644 index 82481687..00000000 --- a/functions/src/validation/items.validation.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ITEM_NAMES } from '@llm-mediation-experiments/utils'; -import { Type } from '@sinclair/typebox'; - -export const ItemData = Type.Union(ITEM_NAMES.map((item) => Type.Literal(item))); diff --git a/utils/package-lock.json b/utils/package-lock.json index 87b81c07..aacecac0 100644 --- a/utils/package-lock.json +++ b/utils/package-lock.json @@ -8,6 +8,7 @@ "name": "@llm-mediation-experiments/utils", "version": "1.0.0", "peerDependencies": { + "@sinclair/typebox": "^0.32.30", "firebase": "^10.11.1" } }, @@ -642,6 +643,12 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "peer": true }, + "node_modules/@sinclair/typebox": { + "version": "0.32.30", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.32.30.tgz", + "integrity": "sha512-IYK1H0k2sHVB2GjzBK2DXBErhex45GoLuPdgn8lNw5t0+5elIuhpixOMPobFyq6kE0AGIBa4+76Ph4enco0q2Q==", + "peer": true + }, "node_modules/@types/node": { "version": "20.12.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.8.tgz", diff --git a/utils/package.json b/utils/package.json index c4f45641..39a5405a 100644 --- a/utils/package.json +++ b/utils/package.json @@ -12,6 +12,7 @@ "dist/**/*" ], "peerDependencies": { + "@sinclair/typebox": "^0.32.30", "firebase": "^10.11.1" } } diff --git a/utils/src/index.ts b/utils/src/index.ts index 5416be22..b4de6bae 100644 --- a/utils/src/index.ts +++ b/utils/src/index.ts @@ -16,3 +16,12 @@ export * from './utils/algebraic.utils'; export * from './utils/cache.utils'; export * from './utils/object.utils'; export * from './utils/string.utils'; + +// Validation (peer dependency: @sinclair/typebox) +export * from './validation/chats.validation'; +export * from './validation/experiments.validation'; +export * from './validation/items.validation'; +export * from './validation/messages.validation'; +export * from './validation/participants.validation'; +export * from './validation/questions.validation'; +export * from './validation/stages.validation'; diff --git a/utils/src/types/api.types.ts b/utils/src/types/api.types.ts index 58ac250f..60693e42 100644 --- a/utils/src/types/api.types.ts +++ b/utils/src/types/api.types.ts @@ -1,9 +1,7 @@ /** Types wrappers for the API */ import type { Timestamp } from 'firebase/firestore'; -import { QuestionAnswer } from './questions.types'; import { StageConfig } from './stages.types'; -import { Votes } from './votes.types'; /** Simple response with data */ export interface SimpleResponse { @@ -19,11 +17,6 @@ export type OnSuccess = (data: T) => Promise | void; export type OnError = ((error: Error, variables: string, context: unknown) => unknown) | undefined; -/** Send additional stage progression information for participants. */ -export interface Progression { - justFinishedStageName?: string; -} - /** Data to be sent to the backend in order to generate a template */ export interface TemplateCreationData { name: string; @@ -36,23 +29,6 @@ export interface ChatToggleUpdate { chatId: string; } -// ********************************************************************************************* // -// STAGE UPDATES // -// ********************************************************************************************* // - -/** Generic stage update data */ -export interface GenericStageUpdate extends Progression { - uid: string; // Participant UID - name: string; // Stage name (unique identifier) - data: T; -} - -export type SurveyStageUpdate = GenericStageUpdate<{ - questions: QuestionAnswer[]; -}>; - -export type LeaderVoteStageUpdate = GenericStageUpdate; - // Helper for Timestamp (make it work between admin & sdk) // Packages firebase-admin/firestore and firebase/firestore use different Timestamp types // This type is a workaround to handle both types in the same codebase diff --git a/utils/src/types/experiments.types.ts b/utils/src/types/experiments.types.ts index 2d91cf83..a8032c53 100644 --- a/utils/src/types/experiments.types.ts +++ b/utils/src/types/experiments.types.ts @@ -16,13 +16,6 @@ export interface Experiment { participants: Record; } -/** Data to be sent to the backend in order to generate an experiment and its participants */ -export interface ExperimentCreationData { - name: string; - stageMap: Record; - numberOfParticipants: number; -} - /** An experiment template */ export interface ExperimentTemplate { id: string; diff --git a/functions/src/validation/chats.validation.ts b/utils/src/validation/chats.validation.ts similarity index 72% rename from functions/src/validation/chats.validation.ts rename to utils/src/validation/chats.validation.ts index 315ca3bf..34b078f5 100644 --- a/functions/src/validation/chats.validation.ts +++ b/utils/src/validation/chats.validation.ts @@ -1,5 +1,5 @@ -import { ChatKind } from '@llm-mediation-experiments/utils'; -import { Type } from '@sinclair/typebox'; +import { Type, type Static } from '@sinclair/typebox'; +import { ChatKind } from '../types/chats.types'; /** Shorthand for strict TypeBox object validation */ const strict = { additionalProperties: false } as const; @@ -21,3 +21,5 @@ export const ChatAboutItemsConfigData = Type.Object( }, strict, ); + +export type ChatAboutItemsConfigData = Static; diff --git a/functions/src/validation/experiments.validation.ts b/utils/src/validation/experiments.validation.ts similarity index 100% rename from functions/src/validation/experiments.validation.ts rename to utils/src/validation/experiments.validation.ts diff --git a/utils/src/validation/items.validation.ts b/utils/src/validation/items.validation.ts new file mode 100644 index 00000000..40dcf124 --- /dev/null +++ b/utils/src/validation/items.validation.ts @@ -0,0 +1,5 @@ +import { Type, type Static } from '@sinclair/typebox'; +import { ITEM_NAMES } from '../types/items.types'; + +export const ItemData = Type.Union(ITEM_NAMES.map((item) => Type.Literal(item))); +export type ItemData = Static; diff --git a/functions/src/validation/messages.validation.ts b/utils/src/validation/messages.validation.ts similarity index 95% rename from functions/src/validation/messages.validation.ts rename to utils/src/validation/messages.validation.ts index 64cdfdd9..54a76ab3 100644 --- a/functions/src/validation/messages.validation.ts +++ b/utils/src/validation/messages.validation.ts @@ -1,5 +1,5 @@ -import { MessageKind } from '@llm-mediation-experiments/utils'; import { Type, type Static } from '@sinclair/typebox'; +import { MessageKind } from '../types/messages.types'; /** Shorthand for strict TypeBox object validation */ const strict = { additionalProperties: false } as const; diff --git a/functions/src/validation/participants.validation.ts b/utils/src/validation/participants.validation.ts similarity index 100% rename from functions/src/validation/participants.validation.ts rename to utils/src/validation/participants.validation.ts diff --git a/functions/src/validation/questions.validation.ts b/utils/src/validation/questions.validation.ts similarity index 97% rename from functions/src/validation/questions.validation.ts rename to utils/src/validation/questions.validation.ts index f5ff9da3..1b9da8ba 100644 --- a/functions/src/validation/questions.validation.ts +++ b/utils/src/validation/questions.validation.ts @@ -1,5 +1,5 @@ -import { SurveyQuestionKind } from '@llm-mediation-experiments/utils'; import { Type } from '@sinclair/typebox'; +import { SurveyQuestionKind } from '../types/questions.types'; import { ItemData } from './items.validation'; /** Shorthand for strict TypeBox object validation */ diff --git a/functions/src/validation/stages.validation.ts b/utils/src/validation/stages.validation.ts similarity index 97% rename from functions/src/validation/stages.validation.ts rename to utils/src/validation/stages.validation.ts index 7f1a5fd2..cb2d0a22 100644 --- a/functions/src/validation/stages.validation.ts +++ b/utils/src/validation/stages.validation.ts @@ -1,5 +1,6 @@ -import { StageKind, Vote } from '@llm-mediation-experiments/utils'; import { Type, type Static } from '@sinclair/typebox'; +import { StageKind } from '../types/stages.types'; +import { Vote } from '../types/votes.types'; import { ChatAboutItemsConfigData } from './chats.validation'; import { CheckQuestionAnswerData, From 522cc90592320882663af357240f8fa4e1da853b Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Thu, 16 May 2024 14:15:42 +0200 Subject: [PATCH 23/35] refactor webapp callable declarations --- .../src/endpoints/participants.endpoints.ts | 2 + utils/src/types/api.types.ts | 15 +---- utils/src/types/messages.types.ts | 23 -------- webapp/src/lib/api/callables.ts | 57 +++---------------- 4 files changed, 11 insertions(+), 86 deletions(-) diff --git a/functions/src/endpoints/participants.endpoints.ts b/functions/src/endpoints/participants.endpoints.ts index f355aec7..94fc0cce 100644 --- a/functions/src/endpoints/participants.endpoints.ts +++ b/functions/src/endpoints/participants.endpoints.ts @@ -36,6 +36,8 @@ export const updateStage = onCall(async (request) => { .doc(`experiments/${experimentId}/participants/${participantId}/stages/${stageName}`); await answerDoc.set(data, { merge: true }); + + return { data: 'success' }; } throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); diff --git a/utils/src/types/api.types.ts b/utils/src/types/api.types.ts index 60693e42..e8dfa76e 100644 --- a/utils/src/types/api.types.ts +++ b/utils/src/types/api.types.ts @@ -1,7 +1,6 @@ /** Types wrappers for the API */ import type { Timestamp } from 'firebase/firestore'; -import { StageConfig } from './stages.types'; /** Simple response with data */ export interface SimpleResponse { @@ -9,7 +8,7 @@ export interface SimpleResponse { } export interface CreationResponse { - uid: string; + id: string; } /** Type for a onSuccess function callback */ @@ -17,18 +16,6 @@ export type OnSuccess = (data: T) => Promise | void; export type OnError = ((error: Error, variables: string, context: unknown) => unknown) | undefined; -/** Data to be sent to the backend in order to generate a template */ -export interface TemplateCreationData { - name: string; - stageMap: Record; -} - -export interface ChatToggleUpdate { - readyToEndChat: boolean; - participantId: string; - chatId: string; -} - // Helper for Timestamp (make it work between admin & sdk) // Packages firebase-admin/firestore and firebase/firestore use different Timestamp types // This type is a workaround to handle both types in the same codebase diff --git a/utils/src/types/messages.types.ts b/utils/src/types/messages.types.ts index 56dcdb78..58c7d5e0 100644 --- a/utils/src/types/messages.types.ts +++ b/utils/src/types/messages.types.ts @@ -36,29 +36,6 @@ export interface MediatorMessage extends MessageBase { export type Message = UserMessage | DiscussItemsMessage | MediatorMessage; -// ********************************************************************************************* // -// MESSAGE MUTATION TYPES // -// ********************************************************************************************* // - -export interface UserMessageMutationData { - chatId: string; - text: string; - fromUserId: string; - // ...the other fields will be filled in by the backend for security -} - -export interface DiscussItemsMessageMutationData { - chatId: string; - text: string; - itemPair: ItemPair; - // itemRatingToDiscuss: ItemRating; -} - -export interface MediatorMessageMutationData { - chatId: string; - text: string; -} - // ********************************************************************************************* // // DEFAULTS // // ********************************************************************************************* // diff --git a/webapp/src/lib/api/callables.ts b/webapp/src/lib/api/callables.ts index bbb52202..52836617 100644 --- a/webapp/src/lib/api/callables.ts +++ b/webapp/src/lib/api/callables.ts @@ -2,16 +2,10 @@ import { CreationResponse, - DiscussItemsMessageMutationData, - Experiment, ExperimentCreationData, - ExperimentTemplate, - GenericStageUpdate, - MediatorMessageMutationData, - ParticipantProfile, + MessageData, SimpleResponse, - TemplateCreationData, - UserMessageMutationData, + StageAnswerData, } from '@llm-mediation-experiments/utils'; import { HttpsCallableResult, httpsCallable } from 'firebase/functions'; import { functions } from './firebase'; @@ -22,52 +16,17 @@ const data = (args?: TArgs) => f(args).then((r) => r.data); -export const experimentsCallable = data( - httpsCallable>(functions, 'experiments'), -); - -export const experimentCallable = data( - httpsCallable<{ experimentUid: string }, Experiment>(functions, 'experiment'), -); - -export const deleteExperimentCallable = data( - httpsCallable<{ experimentId: string }, SimpleResponse>(functions, 'deleteExperiment'), +/** Generic endpoint to create messages */ +export const createMessageCallable = data( + httpsCallable>(functions, 'message'), ); +/** Generic endpoint to create experiments or experiment templates */ export const createExperimentCallable = data( httpsCallable(functions, 'createExperiment'), ); -export const userMessageCallable = data( - httpsCallable(functions, 'userMessage'), -); - -export const discussItemsMessageCallable = data( - httpsCallable( - functions, - 'discussItemsMessage', - ), -); - -export const mediatorMessageCallable = data( - httpsCallable(functions, 'mediatorMessage'), -); - -export const participantCallable = data( - httpsCallable<{ participantUid: string }, ParticipantProfile>(functions, 'participant'), -); - +/** Generic endpoint to update any participant stage */ export const updateStageCallable = data( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - httpsCallable, CreationResponse>(functions, 'updateStage'), -); - -export const templatesCallable = data( - httpsCallable>(functions, 'templates'), -); - -export const createTemplateCallable = data( - httpsCallable(functions, 'createTemplate'), + httpsCallable>(functions, 'updateStage'), ); - -// TODO : create callables for the 3 new cloud functions, and remove the declarations for the old ones From cbecd7f448d3d808ba3f07274672e1c536b55147 Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Thu, 16 May 2024 14:26:01 +0200 Subject: [PATCH 24/35] remove tanstack --- utils/src/types/api.types.ts | 5 --- webapp/package-lock.json | 33 +++---------------- webapp/package.json | 1 - webapp/src/app/app.module.ts | 2 -- .../create-experiment.component.ts | 30 +++++------------ .../exp-leader-vote.component.ts | 5 --- .../exp-survey/exp-survey.component.ts | 16 +-------- .../exp-tos-and-profile.component.ts | 7 +--- webapp/src/lib/types/tanstack.types.ts | 12 ------- 9 files changed, 16 insertions(+), 95 deletions(-) delete mode 100644 webapp/src/lib/types/tanstack.types.ts diff --git a/utils/src/types/api.types.ts b/utils/src/types/api.types.ts index e8dfa76e..067a8b2b 100644 --- a/utils/src/types/api.types.ts +++ b/utils/src/types/api.types.ts @@ -11,11 +11,6 @@ export interface CreationResponse { id: string; } -/** Type for a onSuccess function callback */ -export type OnSuccess = (data: T) => Promise | void; - -export type OnError = ((error: Error, variables: string, context: unknown) => unknown) | undefined; - // Helper for Timestamp (make it work between admin & sdk) // Packages firebase-admin/firestore and firebase/firestore use different Timestamp types // This type is a workaround to handle both types in the same codebase diff --git a/webapp/package-lock.json b/webapp/package-lock.json index c7bb62fa..42710597 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -19,7 +19,6 @@ "@angular/platform-browser-dynamic": "^17.0.6", "@angular/router": "^17.0.6", "@llm-mediation-experiments/utils": "file:../utils", - "@tanstack/angular-query-experimental": "5.28.13", "firebase": "^10.11.0", "lodash": "^4.17.21", "rxjs": "~7.8.0", @@ -73,7 +72,11 @@ }, "../utils": { "name": "@llm-mediation-experiments/utils", - "version": "1.0.0" + "version": "1.0.0", + "peerDependencies": { + "@sinclair/typebox": "^0.32.30", + "firebase": "^10.11.1" + } }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", @@ -5738,32 +5741,6 @@ "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", "dev": true }, - "node_modules/@tanstack/angular-query-experimental": { - "version": "5.28.13", - "resolved": "https://registry.npmjs.org/@tanstack/angular-query-experimental/-/angular-query-experimental-5.28.13.tgz", - "integrity": "sha512-BqMx7Vlq6owT4E6QjgbaNwEOdD1XjK/MHprZyrT2VczPnGnlt9Eco7xIh3FY1egmFYhx4mvFuS5ipQgHlytVlw==", - "dependencies": { - "@tanstack/query-core": "5.28.13", - "tslib": "^2.6.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@angular/common": ">=16.0.0", - "@angular/core": ">=16.0.0" - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.28.13", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.28.13.tgz", - "integrity": "sha512-C3+CCOcza+mrZ7LglQbjeYEOTEC3LV0VN0eYaIN6GvqAZ8Foegdgch7n6QYPtT4FuLae5ALy+m+ZMEKpD6tMCQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", diff --git a/webapp/package.json b/webapp/package.json index ef7b93ae..e79bfb0f 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -22,7 +22,6 @@ "@angular/platform-browser-dynamic": "^17.0.6", "@angular/router": "^17.0.6", "@llm-mediation-experiments/utils": "file:../utils", - "@tanstack/angular-query-experimental": "5.28.13", "firebase": "^10.11.0", "lodash": "^4.17.21", "rxjs": "~7.8.0", diff --git a/webapp/src/app/app.module.ts b/webapp/src/app/app.module.ts index 62950425..d2b0ecd2 100644 --- a/webapp/src/app/app.module.ts +++ b/webapp/src/app/app.module.ts @@ -31,7 +31,6 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; import { RouterModule } from '@angular/router'; -import { QueryClient, provideAngularQuery } from '@tanstack/angular-query-experimental'; import { ExperimenterViewComponent } from './experimenter-view/experimenter-view.component'; import { LlmApiConfigComponent } from './experimenter-view/llm-api-config/llm-api-config.component'; import { FirebaseService } from './firebase.service'; @@ -53,7 +52,6 @@ import { VertexApiService } from './services/vertex-api.service'; FirebaseService, AppStateService, provideHttpClient(), - provideAngularQuery(new QueryClient()), ], bootstrap: [AppComponent], imports: [ diff --git a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts index c8a50825..0fc1f95b 100644 --- a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts +++ b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts @@ -28,13 +28,10 @@ import { getDefaultSurveyConfig, getDefaultTosAndUserProfileConfig, getDefaultVotesConfig, - lookupTable, tryCast, } from '@llm-mediation-experiments/utils'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; import { AppStateService } from 'src/app/services/app-state.service'; import { LocalService } from 'src/app/services/local.service'; -import { createExperimentMutation, createTemplateMutation } from 'src/lib/api/mutations'; const LOCAL_STORAGE_KEY = 'ongoing-experiment-creation'; @@ -62,16 +59,14 @@ const getInitStageData = (): Partial => { styleUrl: './create-experiment.component.scss', }) export class CreateExperimentComponent { - client = injectQueryClient(); + // createExp = createExperimentMutation(this.client, ({ uid }) => { + // localStorage.removeItem(LOCAL_STORAGE_KEY); // Clear local storage + // this.router.navigate(['/experimenter', 'experiment', uid]); + // }); - createExp = createExperimentMutation(this.client, ({ uid }) => { - localStorage.removeItem(LOCAL_STORAGE_KEY); // Clear local storage - this.router.navigate(['/experimenter', 'experiment', uid]); - }); - - createTemplate = createTemplateMutation(this.client, () => { - this.resetExistingStages(); // Reset after setting as template - }); + // createTemplate = createTemplateMutation(this.client, () => { + // this.resetExistingStages(); // Reset after setting as template + // }); public existingStages: Partial[] = []; public currentEditingStageIndex = -1; @@ -326,19 +321,12 @@ export class CreateExperimentComponent { addExperiment() { const stages = this.existingStages as StageConfig[]; - this.createExp.mutate({ - name: this.newExperimentName, - numberOfParticipants: 3, // TODO: provide a way to parametrize this ? - stageMap: lookupTable(stages, 'name'), - }); + // TODO: use new backend } addTemplate() { const stages = this.existingStages as StageConfig[]; - this.createTemplate.mutate({ - name: this.newExperimentName, - stageMap: lookupTable(stages, 'name'), - }); + // TODO: use new backend } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts index 7792cc14..a882e70f 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts @@ -16,8 +16,6 @@ import { } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatRadioModule } from '@angular/material/radio'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { updateLeaderVoteStageMutation } from 'src/lib/api/mutations'; import { StageKind, Vote, VoteForLeaderStageAnswer } from '@llm-mediation-experiments/utils'; import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; @@ -49,10 +47,7 @@ export class ExpLeaderVoteComponent { } private _stage?: CastViewingStage; - private client = injectQueryClient(); - // Vote completion mutation - public voteMutation = updateLeaderVoteStageMutation(this.client); readonly Vote = Vote; public votesForm: FormGroup; diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts index cef79682..3f04c6eb 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts @@ -19,16 +19,8 @@ import { MatInputModule } from '@angular/material/input'; import { MatSliderModule } from '@angular/material/slider'; import { MatButtonModule } from '@angular/material/button'; -import { - StageKind, - SurveyQuestionKind, - SurveyStageUpdate, - assertCast, -} from '@llm-mediation-experiments/utils'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; +import { StageKind, SurveyQuestionKind, assertCast } from '@llm-mediation-experiments/utils'; import { CastViewingStage } from 'src/app/services/participant.service'; -import { updateSurveyStageMutation } from 'src/lib/api/mutations'; -import { MutationType } from 'src/lib/types/tanstack.types'; import { buildQuestionForm, subscribeSignals } from 'src/lib/utils/angular.utils'; import { SurveyCheckQuestionComponent } from './survey-check-question/survey-check-question.component'; import { SurveyRatingQuestionComponent } from './survey-rating-question/survey-rating-question.component'; @@ -86,17 +78,11 @@ export class ExpSurveyComponent { readonly SurveyQuestionKind = SurveyQuestionKind; readonly assertCast = assertCast; - queryClient = injectQueryClient(); - - surveyMutation: MutationType; - constructor(private fb: FormBuilder) { this.questions = fb.array([]); this.surveyForm = fb.group({ questions: this.questions, }); - - this.surveyMutation = updateSurveyStageMutation(this.queryClient); } /** Returns controls for each individual question component */ diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts index b1bc2447..2c533e36 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts @@ -6,15 +6,13 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { HttpClient } from '@angular/common/http'; -import { Component, Input, effect, inject } from '@angular/core'; +import { Component, Input, effect } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; import { StageKind, UnifiedTimestamp } from '@llm-mediation-experiments/utils'; import { Timestamp } from 'firebase/firestore'; @@ -56,9 +54,6 @@ export class ExpTosAndProfileComponent { acceptTosTimestamp: new FormControl(null, Validators.required), }); - http = inject(HttpClient); - queryClient = injectQueryClient(); - value = ''; // Custom pronouns input value constructor(public participantService: ParticipantService) { diff --git a/webapp/src/lib/types/tanstack.types.ts b/webapp/src/lib/types/tanstack.types.ts deleted file mode 100644 index bcd1e278..00000000 --- a/webapp/src/lib/types/tanstack.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** Types helpers for tanstack query */ - -import { CreateMutationResult, CreateQueryResult } from '@tanstack/angular-query-experimental'; - -export type QueryType = CreateQueryResult; - -export type MutationType = CreateMutationResult< - Output, - Error, - Input, - unknown ->; From af19afac27cdb896dbb8d8814486d02a4e2dec7c Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Thu, 16 May 2024 17:40:00 +0200 Subject: [PATCH 25/35] fix frontend build --- .../src/endpoints/participants.endpoints.ts | 45 +++++++++++-------- utils/src/types/questions.types.ts | 2 +- utils/src/types/stages.types.ts | 5 +-- utils/src/utils/object.utils.ts | 22 +++++++++ .../create-experiment.component.html | 12 +---- .../experiment-monitor.component.html | 3 +- .../mediator-chat.component.html | 6 +-- .../mediator-chat/mediator-chat.component.ts | 10 ++--- .../exp-chat/exp-chat.component.html | 6 +-- .../exp-survey/exp-survey.component.ts | 4 +- .../exp-tos-and-profile.component.html | 7 +-- 11 files changed, 67 insertions(+), 55 deletions(-) diff --git a/functions/src/endpoints/participants.endpoints.ts b/functions/src/endpoints/participants.endpoints.ts index 94fc0cce..636ef703 100644 --- a/functions/src/endpoints/participants.endpoints.ts +++ b/functions/src/endpoints/participants.endpoints.ts @@ -5,6 +5,8 @@ import { StageAnswerData, StageKind, SurveyStageConfig, + lookupTable, + mergeableRecord, } from '@llm-mediation-experiments/utils'; import { Value } from '@sinclair/typebox/value'; import * as functions from 'firebase-functions'; @@ -18,25 +20,30 @@ export const updateStage = onCall(async (request) => { if (Value.Check(StageAnswerData, data)) { const { experimentId, participantId, stageName, stage } = data; - // Validation - let error = false; + const answerDoc = app + .firestore() + .doc(`experiments/${experimentId}/participants/${participantId}/stages/${stageName}`); + + // Validation & merging answers switch (stage.kind) { case StageKind.VoteForLeader: - if (participantId in stage.votes) error = true; + if (participantId in stage.votes) + throw new functions.https.HttpsError('invalid-argument', 'Invalid answers'); + await answerDoc.set({ votes: stage.votes }, { merge: true }); break; + case StageKind.TakeSurvey: - error = await validateSurveyAnswers(experimentId, stageName, stage.answers); + await validateSurveyAnswers(experimentId, stageName, stage.answers); + + // Prepare data to merge individual answers into the firestore document + const data = { + kind: StageKind.TakeSurvey, + ...mergeableRecord(stage.answers, 'answers'), + }; + await answerDoc.set(data, { merge: true }); break; } - if (error) throw new functions.https.HttpsError('invalid-argument', 'Invalid answers'); - - const answerDoc = app - .firestore() - .doc(`experiments/${experimentId}/participants/${participantId}/stages/${stageName}`); - - await answerDoc.set(data, { merge: true }); - return { data: 'success' }; } @@ -48,16 +55,18 @@ const validateSurveyAnswers = async ( experimentId: string, stageName: string, answers: Record, -): Promise => { +) => { const configDoc = app.firestore().doc(`experiments/${experimentId}/stages/${stageName}`); const data = (await configDoc.get()).data() as SurveyStageConfig | undefined; - if (!data) return false; + if (!data) throw new functions.https.HttpsError('invalid-argument', 'Invalid answers'); + + // Question configs are stored in an array. Make a "id" lookup table for easier access + const questions = lookupTable(data.questions, 'id'); for (const answer of Object.values(answers)) { - const config = data.questions[answer.id]; - if (!config || config.kind !== answer.kind) return false; + const config = questions[answer.id]; + if (!config || config.kind !== answer.kind) + throw new functions.https.HttpsError('invalid-argument', 'Invalid answers'); } - - return true; }; diff --git a/utils/src/types/questions.types.ts b/utils/src/types/questions.types.ts index dd33fabb..e0a4ed19 100644 --- a/utils/src/types/questions.types.ts +++ b/utils/src/types/questions.types.ts @@ -15,7 +15,7 @@ export enum SurveyQuestionKind { interface BaseQuestionConfig { kind: SurveyQuestionKind; - id: number; + id: number; // Note that the question id is not related to the question's position in the survey questionText: string; } diff --git a/utils/src/types/stages.types.ts b/utils/src/types/stages.types.ts index cfa96a87..f9bcd331 100644 --- a/utils/src/types/stages.types.ts +++ b/utils/src/types/stages.types.ts @@ -53,9 +53,7 @@ export interface AcceptTosAndSetProfileStageConfig extends BaseStageConfig { export interface SurveyStageConfig extends BaseStageConfig { kind: StageKind.TakeSurvey; - // Store questions in a map to allow for easy access by id. - // The question id is actually its index as if this was an array. - questions: Record; + questions: QuestionConfig[]; } export interface GroupChatStageConfig extends BaseStageConfig { @@ -95,6 +93,7 @@ interface BaseStageAnswer { export interface SurveyStageAnswer extends BaseStageAnswer { kind: StageKind.TakeSurvey; + // For convenience, we store answers in a `question id` -> `answer` record answers: Record; } diff --git a/utils/src/utils/object.utils.ts b/utils/src/utils/object.utils.ts index 19753ab0..caaba988 100644 --- a/utils/src/utils/object.utils.ts +++ b/utils/src/utils/object.utils.ts @@ -66,3 +66,25 @@ export const mergeByKey = >( ...lookupTable(incomingArray, key), }); }; + +/** Converts a record into an object that allows record field merging into firestore. + * When updating a firestore document, you can only merge top level attributes, not values nested in object attributes. + * In order to merge nested values, you need to flatten the record into a single level object with keys that represent the path to the nested value. + * + * @example + * const firestoreData = { answers: { q1: "maybe", q2: "maybe", q3: "maybe"}} + * const answers = { q1: "yes", q2: "no" }; // Incoming data to be merged + * + * // mergeableAnsers = { "answers.q1": "yes", "answers.q2": "no" }; + * const mergeableAnsers = mergeableRecord(answers, "answers") + * firestoreDoc.set(mergeableAnsers, { merge: true }); + */ +export const mergeableRecord = (record: Record, fieldName: string) => { + const result: Record = {}; + + Object.entries(record).forEach(([key, value]) => { + result[`${fieldName}.${key}`] = value as V; + }); + + return result; +}; diff --git a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.html b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.html index ec4da06f..9a5463fc 100644 --- a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.html +++ b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.html @@ -20,11 +20,7 @@

Create an experiment:

mat-raised-button color="secondary" (click)="addTemplate()" - [disabled]=" - this.experimentSetupIncomplete() || - this.createTemplate.isPending() || - this.createExp.isPending() - " + [disabled]="this.experimentSetupIncomplete()" style="margin-right: 5px" > Save as template @@ -33,11 +29,7 @@

Create an experiment:

mat-raised-button color="primary" (click)="addExperiment()" - [disabled]=" - this.experimentSetupIncomplete() || - this.createTemplate.isPending() || - this.createExp.isPending() - " + [disabled]="this.experimentSetupIncomplete()" > Add new experiment diff --git a/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.html b/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.html index 0bb08e4d..48b6bb4c 100644 --- a/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.html +++ b/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.html @@ -60,8 +60,7 @@

Experiment settings:

mat-raised-button color="warn" aria-label="Delete" - (click)="deleteExperiment()" - [disabled]="rmExperiment.isPending()" + (click)="deleteExperimentAndNavigate()" > delete Delete this experiment diff --git a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.html b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.html index 0742ed52..8e3494b7 100644 --- a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.html +++ b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.html @@ -3,11 +3,11 @@
@for (message of chatRepository()?.messages(); track message.uid) {
- @if (message.messageType === 'userMessage') { + @if (message.kind === 'userMessage') { - } @else if (message.messageType === 'mediatorMessage') { + } @else if (message.kind === 'mediatorMessage') { - } @else if (message.messageType === 'discussItemsMessage') { + } @else if (message.kind === 'discussItemsMessage') { diff --git a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts index 5491eb18..88565fd3 100644 --- a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts +++ b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts @@ -6,12 +6,12 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { GroupChatStageConfig, + MessageKind, ParticipantProfileExtended, lookupTable, } from '@llm-mediation-experiments/utils'; import { AppStateService } from 'src/app/services/app-state.service'; import { VertexApiService } from 'src/app/services/vertex-api.service'; -import { mediatorMessageMutation } from 'src/lib/api/mutations'; import { ChatRepository } from 'src/lib/repositories/chat.repository'; import { FewShotTemplate } from 'src/lib/text-templates/fewshot_template'; import { preparePalm2Request, sendPalm2Request } from 'src/lib/text-templates/llm_vertexapi_palm2'; @@ -53,7 +53,6 @@ export class MediatorChatComponent { public chatRepository: Signal = signal(undefined); // Message mutation & form - public messageMutation = mediatorMessageMutation(); public message = new FormControl('', Validators.required); public defaultPrefix: string = @@ -86,10 +85,7 @@ export class MediatorChatComponent { sendMessage() { if (!this.message.valid) return; - this.messageMutation.mutate({ - chatId: this.chatConfig()!.chatId, - text: this.message.value!, - }); + // TODO: use new backend this.message.setValue(''); } @@ -131,7 +127,7 @@ ${this.suffix}`; .map((m) => ({ message: m.text, username: - m.messageType === 'userMessage' + m.kind === MessageKind.UserMessage ? participantsLookup[m.fromPublicParticipantId].name ?? 'User' : 'Mediator', })) ?? []; diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html index c7871aeb..1e202c51 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html @@ -26,11 +26,11 @@
@for (message of chat?.messages(); track $index) {
- @if (message.messageType === 'userMessage') { + @if (message.kind === 'userMessage') { - } @else if (message.messageType === 'mediatorMessage') { + } @else if (message.kind === 'mediatorMessage') { - } @else if (message.messageType === 'discussItemsMessage') { + } @else if (message.kind === 'discussItemsMessage') { diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts index 3f04c6eb..3be65087 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts @@ -56,8 +56,8 @@ export class ExpSurveyComponent { [this.stage.config, this.stage.answers ?? signal(undefined)], ({ questions }, answers) => { this.questions.clear(); - questions.forEach((config, i) => { - const answer = answers?.answers[i]; + questions.forEach((config) => { + const answer = answers?.answers[config.id]; // The config serves as the source of truth for the question type // The answer, if defined, will be used to populate the form this.questions.push(buildQuestionForm(this.fb, config, answer)); diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html index e810be50..8ce7c2c6 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html @@ -79,12 +79,7 @@

Terms of Service

I accept the terms of service -
From cd3e78da4cef710a12384821670b19e88bf4dd4e Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Thu, 16 May 2024 18:54:07 +0200 Subject: [PATCH 26/35] move mutation functions to repositories --- functions/src/endpoints/messages.endpoints.ts | 18 ++++- utils/src/types/questions.types.ts | 10 +-- utils/src/validation/messages.validation.ts | 2 +- .../experiment-monitor.component.ts | 7 +- .../exp-survey/exp-survey.component.ts | 12 ++-- webapp/src/lib/api/mutations.ts | 65 ----------------- .../src/lib/repositories/chat.repository.ts | 58 +++++++++++++++- .../lib/repositories/experiment.repository.ts | 13 +++- .../repositories/experimenter.repository.ts | 37 +++++++++- .../repositories/participant.repository.ts | 69 ++++++++++++++++++- 10 files changed, 203 insertions(+), 88 deletions(-) delete mode 100644 webapp/src/lib/api/mutations.ts diff --git a/functions/src/endpoints/messages.endpoints.ts b/functions/src/endpoints/messages.endpoints.ts index 2125e484..398a19f2 100644 --- a/functions/src/endpoints/messages.endpoints.ts +++ b/functions/src/endpoints/messages.endpoints.ts @@ -4,7 +4,7 @@ import * as functions from 'firebase-functions'; import { onCall } from 'firebase-functions/v2/https'; import { app } from '../app'; -import { MessageData, MessageKind } from '@llm-mediation-experiments/utils'; +import { MessageData, MessageKind, ParticipantProfile } from '@llm-mediation-experiments/utils'; import { Timestamp } from 'firebase-admin/firestore'; import { AuthGuard } from '../utils/auth-guard'; @@ -22,6 +22,22 @@ export const message = onCall(async (request) => { .collection(`experiments/${data.experimentId}/participants`) .get(); + // If it's a user message, replace the private id by the public id + // Participant private IDs must be masked because they are used for authentication + if (data.message.kind === MessageKind.UserMessage) { + const participantDoc = await app + .firestore() + .doc( + `experiments/${data.experimentId}/participants/${data.message.fromPrivateParticipantId}`, + ) + .get(); + + // Ignore ts warnings because we immediately write the data to firestore and do not need to keep consistent types until then + const participant = participantDoc.data() as ParticipantProfile; // @ts-ignore + delete data.message.fromPrivateParticipantId; // @ts-ignore + data.message.fromPublicParticipantId = participant.publicId; // see [UserMessage] type + } + // Create all messages in transaction await app.firestore().runTransaction(async (transaction) => { participants.docs.forEach((participant) => { diff --git a/utils/src/types/questions.types.ts b/utils/src/types/questions.types.ts index e0a4ed19..bc09c2ca 100644 --- a/utils/src/types/questions.types.ts +++ b/utils/src/types/questions.types.ts @@ -59,26 +59,26 @@ interface BaseQuestionAnswer { export interface TextQuestionAnswer extends BaseQuestionAnswer { kind: SurveyQuestionKind.Text; - answerText: string | null; + answerText: string; } export interface CheckQuestionAnswer extends BaseQuestionAnswer { kind: SurveyQuestionKind.Check; - checkMark: boolean | null; + checkMark: boolean; } export interface RatingQuestionAnswer extends BaseQuestionAnswer { kind: SurveyQuestionKind.Rating; - choice: ItemName | null; - confidence: number | null; // Confidence in the choice, from 0 to 1 + choice: ItemName; + confidence: number; // Confidence in the choice, from 0 to 1 } export interface ScaleQuestionAnswer extends BaseQuestionAnswer { kind: SurveyQuestionKind.Scale; - score: number | null; // Score on a scale of 0 to 10 + score: number; // Score on a scale of 0 to 10 } export type QuestionAnswer = diff --git a/utils/src/validation/messages.validation.ts b/utils/src/validation/messages.validation.ts index 54a76ab3..962368de 100644 --- a/utils/src/validation/messages.validation.ts +++ b/utils/src/validation/messages.validation.ts @@ -9,7 +9,7 @@ export const UserMessageData = Type.Object( { kind: Type.Literal(MessageKind.UserMessage), text: Type.String({ minLength: 1 }), - fromPublicParticipantId: Type.String({ minLength: 1 }), + fromPrivateParticipantId: Type.String({ minLength: 1 }), }, strict, ); diff --git a/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts b/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts index a705e31e..cd3b9599 100644 --- a/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts +++ b/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts @@ -12,7 +12,6 @@ import { isOfKind, } from '@llm-mediation-experiments/utils'; import { AppStateService } from 'src/app/services/app-state.service'; -import { deleteExperiment } from 'src/lib/api/mutations'; import { ExperimentRepository } from 'src/lib/repositories/experiment.repository'; import { MediatorChatComponent } from '../mediator-chat/mediator-chat.component'; @@ -77,9 +76,9 @@ export class ExperimentMonitorComponent { } deleteExperimentAndNavigate() { - const experimentUid = this.experimentId(); - if (experimentUid && confirm('⚠️ This will delete the experiment! Are you sure?')) { - deleteExperiment(experimentUid).then(() => { + const experiment = this.experiment(); + if (experiment && confirm('⚠️ This will delete the experiment! Are you sure?')) { + experiment.delete().then(() => { // Redirect to settings page. this.router.navigate(['/experimenter', 'settings']); }); diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts index 3be65087..14c97eb8 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts @@ -55,12 +55,12 @@ export class ExpSurveyComponent { subscribeSignals( [this.stage.config, this.stage.answers ?? signal(undefined)], ({ questions }, answers) => { - this.questions.clear(); + this.answers.clear(); questions.forEach((config) => { const answer = answers?.answers[config.id]; // The config serves as the source of truth for the question type // The answer, if defined, will be used to populate the form - this.questions.push(buildQuestionForm(this.fb, config, answer)); + this.answers.push(buildQuestionForm(this.fb, config, answer)); }); }, ); @@ -72,22 +72,22 @@ export class ExpSurveyComponent { private _stage?: CastViewingStage; - public questions: FormArray; + public answers: FormArray; public surveyForm: FormGroup; readonly SurveyQuestionKind = SurveyQuestionKind; readonly assertCast = assertCast; constructor(private fb: FormBuilder) { - this.questions = fb.array([]); + this.answers = fb.array([]); this.surveyForm = fb.group({ - questions: this.questions, + answers: this.answers, }); } /** Returns controls for each individual question component */ get questionControls() { - return this.questions.controls as FormGroup[]; + return this.answers.controls as FormGroup[]; } nextStep() { diff --git a/webapp/src/lib/api/mutations.ts b/webapp/src/lib/api/mutations.ts deleted file mode 100644 index 603a55f7..00000000 --- a/webapp/src/lib/api/mutations.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** Tanstack angular mutations (not anymore, remove tanstack after this) - */ - -import { ParticipantProfileBase } from '@llm-mediation-experiments/utils'; - -// TODO: put all these functions in the relevant repositories instead ! - -// ********************************************************************************************* // -// DELETE // -// ********************************************************************************************* // - -import { deleteDoc, doc, updateDoc } from 'firebase/firestore'; -import { firestore } from './firebase'; - -/** Delete an experiment. - * @rights Experimenter - */ -export const deleteExperiment = (experimentId: string) => - deleteDoc(doc(firestore, 'experiments', experimentId)); - -/** Delete a template. - * @rights Experimenter - */ -export const deleteTemplate = (templateId: string) => - deleteDoc(doc(firestore, 'templates', templateId)); - -// ********************************************************************************************* // -// CHAT // -// ********************************************************************************************* // - -/** Mark the given participant as ready to end the chat, or to go to the next pair - * @rights Participant - */ -export const markReadyToEndChat = (experimentId: string, participantId: string, chatId: string) => - updateDoc( - doc(firestore, 'experiments', experimentId, 'participants', participantId, 'chats', chatId), - { - readyToEndChat: true, - }, - ); - -// ********************************************************************************************* // -// PROFILE & TOS // -// ********************************************************************************************* // - -/** Update a participant's profile and acceptance of TOS. - * @rights Participant - */ -export const updateTOSAndProfile = ( - experimentId: string, - participantId: string, - data: Partial, -) => updateDoc(doc(firestore, 'experiments', experimentId, 'participants', participantId), data); - -// ********************************************************************************************* // -// STAGES // -// ********************************************************************************************* // - -/** Update a participant's `workingOnStageName` - * @rights Participant - */ -export const workOnStage = (experimentId: string, participantId: string, stageName: string) => - updateDoc(doc(firestore, 'experiments', experimentId, 'participants', participantId), { - workingOnStageName: stageName, - }); diff --git a/webapp/src/lib/repositories/chat.repository.ts b/webapp/src/lib/repositories/chat.repository.ts index f229e86b..1af9ca96 100644 --- a/webapp/src/lib/repositories/chat.repository.ts +++ b/webapp/src/lib/repositories/chat.repository.ts @@ -1,6 +1,7 @@ import { Signal, WritableSignal, signal } from '@angular/core'; -import { ChatAnswer, Message } from '@llm-mediation-experiments/utils'; -import { collection, doc, onSnapshot, orderBy, query } from 'firebase/firestore'; +import { ChatAnswer, Message, MessageKind } from '@llm-mediation-experiments/utils'; +import { collection, doc, onSnapshot, orderBy, query, updateDoc } from 'firebase/firestore'; +import { createMessageCallable } from '../api/callables'; import { firestore } from '../api/firebase'; import { collectSnapshotWithId } from '../utils/firestore.utils'; import { BaseRepository } from './base.repository'; @@ -64,4 +65,57 @@ export class ChatRepository extends BaseRepository { ), ); } + + // ******************************************************************************************* // + // MUTATIONS // + // ******************************************************************************************* // + + /** Mark this participant as ready to end the chat, or ready to discuss about the next pair of items. + * @rights Participant + */ + async markReadyToEndChat() { + return updateDoc( + doc( + firestore, + 'experiments', + this.experimentId, + 'participants', + this.participantId, + 'chats', + this.chatId, + ), + { + readyToEndChat: true, + }, + ); + } + + /** Send a message as a participant. + * @rights Participant + */ + async sendUserMessage(text: string) { + return createMessageCallable({ + chatId: this.chatId, + experimentId: this.experimentId, + message: { + kind: MessageKind.UserMessage, + fromPrivateParticipantId: this.participantId, + text, + }, + }); + } + + /** Send a message as a mediator. + * @rights Experimenter + */ + async sendMediatorMessage(text: string) { + return createMessageCallable({ + chatId: this.chatId, + experimentId: this.experimentId, + message: { + kind: MessageKind.MediatorMessage, + text, + }, + }); + } } diff --git a/webapp/src/lib/repositories/experiment.repository.ts b/webapp/src/lib/repositories/experiment.repository.ts index 6ead30c7..472277db 100644 --- a/webapp/src/lib/repositories/experiment.repository.ts +++ b/webapp/src/lib/repositories/experiment.repository.ts @@ -1,6 +1,6 @@ import { Signal, WritableSignal, computed, signal } from '@angular/core'; import { Experiment, PublicStageData, StageConfig } from '@llm-mediation-experiments/utils'; -import { collection, doc, onSnapshot } from 'firebase/firestore'; +import { collection, deleteDoc, doc, onSnapshot } from 'firebase/firestore'; import { firestore } from '../api/firebase'; import { BaseRepository } from './base.repository'; @@ -102,4 +102,15 @@ export class ExperimentRepository extends BaseRepository { ); }); } + + // ******************************************************************************************* // + // MUTATIONS // + // ******************************************************************************************* // + + /** Delete the experiment.. + * @rights Experimenter + */ + async delete() { + return deleteDoc(doc(firestore, 'experiments', this.uid)); + } } diff --git a/webapp/src/lib/repositories/experimenter.repository.ts b/webapp/src/lib/repositories/experimenter.repository.ts index f7a433c2..2ef46ff4 100644 --- a/webapp/src/lib/repositories/experimenter.repository.ts +++ b/webapp/src/lib/repositories/experimenter.repository.ts @@ -5,9 +5,11 @@ import { ExperimentTemplate, ExperimentTemplateExtended, ParticipantProfileExtended, + StageConfig, lookupTable, } from '@llm-mediation-experiments/utils'; -import { collection, doc, getDoc, getDocs, onSnapshot } from 'firebase/firestore'; +import { collection, deleteDoc, doc, getDoc, getDocs, onSnapshot } from 'firebase/firestore'; +import { createExperimentCallable } from '../api/callables'; import { firestore } from '../api/firebase'; import { collectSnapshotWithId } from '../utils/firestore.utils'; import { BaseRepository } from './base.repository'; @@ -66,6 +68,39 @@ export class ExperimenterRepository extends BaseRepository { return _signal; } + + // ******************************************************************************************* // + // MUTATIONS // + // ******************************************************************************************* // + + /** Delete a template. + * @rights Experimenter + */ + async deleteTemplate(templateId: string) { + return deleteDoc(doc(firestore, 'templates', templateId)); + } + + /** Create an experiment. + * @rights Experimenter + */ + async createExperiment(name: string, stages: StageConfig[]) { + return createExperimentCallable({ + type: 'experiments', + metadata: { name }, + stages, + }); + } + + /** Create an experiment template. + * @rights Experimenter + */ + async createTemplate(name: string, stages: StageConfig[]) { + return createExperimentCallable({ + type: 'templates', + metadata: { name }, + stages, + }); + } } /** Load a template with all its stage config data. diff --git a/webapp/src/lib/repositories/participant.repository.ts b/webapp/src/lib/repositories/participant.repository.ts index 66081bd9..ab5009d5 100644 --- a/webapp/src/lib/repositories/participant.repository.ts +++ b/webapp/src/lib/repositories/participant.repository.ts @@ -1,6 +1,15 @@ import { Signal, WritableSignal, signal } from '@angular/core'; -import { ParticipantProfile, StageAnswer } from '@llm-mediation-experiments/utils'; -import { collection, doc, onSnapshot } from 'firebase/firestore'; +import { + ParticipantProfile, + ParticipantProfileBase, + QuestionAnswer, + StageAnswer, + StageKind, + Votes, + lookupTable, +} from '@llm-mediation-experiments/utils'; +import { collection, doc, onSnapshot, updateDoc } from 'firebase/firestore'; +import { updateStageCallable } from '../api/callables'; import { firestore } from '../api/firebase'; import { BaseRepository } from './base.repository'; @@ -56,4 +65,60 @@ export class ParticipantRepository extends BaseRepository { ), ); } + + // ******************************************************************************************* // + // MUTATIONS // + // ******************************************************************************************* // + + /** Update this participants's profile and acceptance of the Terms of Service. + * @rights Participant + */ + async updateProfile(data: Partial) { + return updateDoc( + doc(firestore, 'experiments', this.experimentId, 'participants', this.participantId), + data, + ); + } + + /** Update this participant's `workingOnStageName` + * @rights Participant + */ + async workOnStage(stageName: string) { + return updateDoc( + doc(firestore, 'experiments', this.experimentId, 'participants', this.participantId), + { + workingOnStageName: stageName, + }, + ); + } + + /** Update this participant's `workingOnStageName` + * @rights Participant + */ + async updateSurveyStage(stageName: string, answers: QuestionAnswer[]) { + return updateStageCallable({ + experimentId: this.experimentId, + participantId: this.participantId, + stageName, + stage: { + kind: StageKind.TakeSurvey, + answers: lookupTable(answers, 'id'), + }, + }); + } + + /** Update this participant's `workingOnStageName` + * @rights Participant + */ + async updateVoteForLeaderStage(stageName: string, votes: Votes) { + return updateStageCallable({ + experimentId: this.experimentId, + participantId: this.participantId, + stageName, + stage: { + kind: StageKind.VoteForLeader, + votes, + }, + }); + } } From 002546579865e980ef08ae1303ff767305bb251a Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Thu, 16 May 2024 19:54:03 +0200 Subject: [PATCH 27/35] integrate mutations within the frontend (wip) --- .../create-experiment.component.ts | 25 ++++---- .../mediator-chat/mediator-chat.component.ts | 4 +- .../exp-chat/exp-chat.component.ts | 60 ++++++++++++++----- .../exp-leader-reveal.component.ts | 4 +- .../exp-leader-vote.component.ts | 7 ++- .../exp-survey/exp-survey.component.ts | 14 +++-- .../exp-tos-and-profile.component.ts | 12 +--- .../src/app/services/participant.service.ts | 19 +++++- webapp/src/lib/utils/angular.utils.ts | 60 ++++++++++++++++--- 9 files changed, 148 insertions(+), 57 deletions(-) diff --git a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts index 0fc1f95b..30ec204b 100644 --- a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts +++ b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts @@ -32,6 +32,7 @@ import { } from '@llm-mediation-experiments/utils'; import { AppStateService } from 'src/app/services/app-state.service'; import { LocalService } from 'src/app/services/local.service'; +import { ExperimenterRepository } from 'src/lib/repositories/experimenter.repository'; const LOCAL_STORAGE_KEY = 'ongoing-experiment-creation'; @@ -59,15 +60,6 @@ const getInitStageData = (): Partial => { styleUrl: './create-experiment.component.scss', }) export class CreateExperimentComponent { - // createExp = createExperimentMutation(this.client, ({ uid }) => { - // localStorage.removeItem(LOCAL_STORAGE_KEY); // Clear local storage - // this.router.navigate(['/experimenter', 'experiment', uid]); - // }); - - // createTemplate = createTemplateMutation(this.client, () => { - // this.resetExistingStages(); // Reset after setting as template - // }); - public existingStages: Partial[] = []; public currentEditingStageIndex = -1; public newExperimentName = ''; @@ -95,12 +87,15 @@ export class CreateExperimentComponent { // Convenience signals public templates: Signal; + private experimenter: ExperimenterRepository; + constructor( private router: Router, private localStore: LocalService, private appState: AppStateService, ) { this.templates = appState.experimenter.get().templates; + this.experimenter = appState.experimenter.get(); // Set the current experiment template to the first fetched template effect( @@ -318,15 +313,19 @@ export class CreateExperimentComponent { } /** Create the experiment and send it to be stored in the database */ - addExperiment() { + async addExperiment() { const stages = this.existingStages as StageConfig[]; + const { id } = await this.experimenter.createExperiment(this.newExperimentName, stages); - // TODO: use new backend + // Navigate to the experiment page after creation + localStorage.removeItem(LOCAL_STORAGE_KEY); // Clear local storage + this.router.navigate(['/experimenter', 'experiment', id]); } - addTemplate() { + async addTemplate() { const stages = this.existingStages as StageConfig[]; - // TODO: use new backend + await this.experimenter.createTemplate(this.newExperimentName, stages); + this.resetExistingStages(); } } diff --git a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts index 88565fd3..60fe8409 100644 --- a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts +++ b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts @@ -83,9 +83,9 @@ export class MediatorChatComponent { } sendMessage() { - if (!this.message.valid) return; + if (!this.message.valid || !this.message.value) return; - // TODO: use new backend + this.chatRepository()?.sendMediatorMessage(this.message.value); this.message.setValue(''); } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts index 2efc762b..38c88235 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts @@ -12,13 +12,21 @@ import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { ITEMS, ItemPair, StageKind, getDefaultItemPair } from '@llm-mediation-experiments/utils'; +import { + ChatKind, + GroupChatStageConfig, + GroupChatStagePublicData, + ITEMS, + ItemPair, + StageKind, + assertCast, + getDefaultItemPair, +} from '@llm-mediation-experiments/utils'; import { AppStateService } from 'src/app/services/app-state.service'; import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; -import { markReadyToEndChat } from 'src/lib/api/mutations'; import { ChatRepository } from 'src/lib/repositories/chat.repository'; -import { localStorageTimer, subscribeSignal } from 'src/lib/utils/angular.utils'; +import { localStorageTimer, subscribeSignal, subscribeSignals } from 'src/lib/utils/angular.utils'; import { ChatDiscussItemsMessageComponent } from './chat-discuss-items-message/chat-discuss-items-message.component'; import { ChatMediatorMessageComponent } from './chat-mediator-message/chat-mediator-message.component'; import { ChatUserMessageComponent } from './chat-user-message/chat-user-message.component'; @@ -74,6 +82,13 @@ export class ExpChatComponent { const { item1, item2 } = config.chatConfig.ratingsToDiscuss[0]; this.currentRatingsToDiscuss = signal({ item1, item2 }); }); + + // Automatic next step progression when the chat has ended + if (this.participantService.workingOnStageName() === this.stage.config().name) { + subscribeSignals([this.stage.config, this.stage.public!], (config, pub) => { + if (chatReadyToEnd(config, pub)) this.nextStep(); + }); + } } get stage() { return this._stage as CastViewingStage; @@ -105,28 +120,41 @@ export class ExpChatComponent { // return this.stageData().isSilent !== false; } - sendMessage() { - if (!this.message.valid) return; + async sendMessage() { + if (!this.message.valid || !this.message.value) return; - // TODO: use new backend - // this.messageMutation.mutate({ - // chatId: this.stage.config.chatId, - // text: this.message.value!, - // fromUserId: this.participant.userData()!.uid, - // }); + this.chat?.sendUserMessage(this.message.value); this.message.setValue(''); } toggleEndChat() { if (this.readyToEndChat()) return; - markReadyToEndChat( - this.participantService.experimentId()!, - this.participantService.participantId()!, - this.stage.config().chatId, - ); + this.chat?.markReadyToEndChat(); this.message.disable(); this.timer.remove(); } + + async nextStep() { + await this.participantService.workOnNextStage(); + this.timer.remove(); + } } + +const chatReadyToEnd = (config: GroupChatStageConfig, pub: GroupChatStagePublicData) => { + // If someone is not ready to end, return false + if (Object.values(pub.readyToEndChat).some((bool) => !bool)) return false; + + // If this is a chat about items, all items must have been discussed + if (config.chatConfig.kind === ChatKind.ChatAboutItems) { + if ( + assertCast(pub.chatData, ChatKind.ChatAboutItems).currentRatingIndex < + config.chatConfig.ratingsToDiscuss.length + ) + return false; + } + + // If all checks passed, the chat stage is ready to end + return true; +}; diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts index 9745b1fb..a2aee62c 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts @@ -48,7 +48,7 @@ export class ExpLeaderRevealComponent { this.everyoneReachedThisStage = signal(false); } - nextStep() { - // TODO: use the new backend + async nextStep() { + await this.participantService.workOnNextStage(); } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts index a882e70f..4d8f6437 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts @@ -79,8 +79,11 @@ export class ExpLeaderVoteComponent { }); } - nextStep() { - // TODO: use new backend + async nextStep() { + await this.participantService + .participant() + ?.updateVoteForLeaderStage(this.stage.config().name, this.votesForm.value.votes); + await this.participantService.workOnNextStage(); } /** Call this when the input or the other participants signal change in order to stay up to date */ diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts index 14c97eb8..40794818 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts @@ -20,7 +20,7 @@ import { MatSliderModule } from '@angular/material/slider'; import { MatButtonModule } from '@angular/material/button'; import { StageKind, SurveyQuestionKind, assertCast } from '@llm-mediation-experiments/utils'; -import { CastViewingStage } from 'src/app/services/participant.service'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; import { buildQuestionForm, subscribeSignals } from 'src/lib/utils/angular.utils'; import { SurveyCheckQuestionComponent } from './survey-check-question/survey-check-question.component'; import { SurveyRatingQuestionComponent } from './survey-rating-question/survey-rating-question.component'; @@ -78,7 +78,10 @@ export class ExpSurveyComponent { readonly SurveyQuestionKind = SurveyQuestionKind; readonly assertCast = assertCast; - constructor(private fb: FormBuilder) { + constructor( + private fb: FormBuilder, + public participantService: ParticipantService, + ) { this.answers = fb.array([]); this.surveyForm = fb.group({ answers: this.answers, @@ -90,7 +93,10 @@ export class ExpSurveyComponent { return this.answers.controls as FormGroup[]; } - nextStep() { - // TODO: use new backend + async nextStep() { + await this.participantService + .participant() + ?.updateSurveyStage(this.stage.config().name, this.surveyForm.value.answers); + await this.participantService.workOnNextStage(); } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts index 2c533e36..4146648c 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts @@ -17,7 +17,6 @@ import { MatRadioModule } from '@angular/material/radio'; import { StageKind, UnifiedTimestamp } from '@llm-mediation-experiments/utils'; import { Timestamp } from 'firebase/firestore'; import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; -import { updateTOSAndProfile } from 'src/lib/api/mutations'; enum Pronouns { HeHim = 'He/Him', @@ -90,13 +89,8 @@ export class ExpTosAndProfileComponent { }); } - nextStep() { - updateTOSAndProfile( - this.participantService.experimentId()!, - this.participantService.participantId()!, - this.profileFormControl.value, - ); - - // TODO: naviguate to next stage on success, after editing "viewing stage", on success of it + async nextStep() { + await this.participantService.participant()?.updateProfile(this.profileFormControl.value); + await this.participantService.workOnNextStage(); } } diff --git a/webapp/src/app/services/participant.service.ts b/webapp/src/app/services/participant.service.ts index bff93ea3..95f9b722 100644 --- a/webapp/src/app/services/participant.service.ts +++ b/webapp/src/app/services/participant.service.ts @@ -3,6 +3,7 @@ */ import { Injectable, Signal, computed, signal, untracked } from '@angular/core'; +import { Router } from '@angular/router'; import { ParticipantProfile, PublicStageData, @@ -40,7 +41,10 @@ export class ParticipantService { public workingOnStageName: Signal = signal(undefined); public futureStageNames: Signal = signal([]); - constructor(public readonly appState: AppStateService) {} + constructor( + public readonly appState: AppStateService, + private router: Router, + ) {} /** Initialize the service with the participant and experiment IDs */ initialize( @@ -134,6 +138,19 @@ export class ParticipantService { return this.appState.chats.get({ experimentId, participantId, chatId: id }); }); } + + /** Update the participant's workingOnStageName to the next stage + * @rights Participant + */ + async workOnNextStage() { + const nextStage = this.futureStageNames()[0]; + + if (!nextStage) return; + await this.participant()?.workOnStage(nextStage); + await this.router.navigate(['/participant', this.experimentId()!, this.participantId()!], { + queryParams: { stage: nextStage }, + }); + } } // ********************************************************************************************* // diff --git a/webapp/src/lib/utils/angular.utils.ts b/webapp/src/lib/utils/angular.utils.ts index 3f40b4d9..90a222ba 100644 --- a/webapp/src/lib/utils/angular.utils.ts +++ b/webapp/src/lib/utils/angular.utils.ts @@ -12,12 +12,16 @@ import { import { ActivatedRoute } from '@angular/router'; import { CheckQuestionAnswer, + CheckQuestionConfig, QuestionAnswer, QuestionConfig, RatingQuestionAnswer, + RatingQuestionConfig, ScaleQuestionAnswer, + ScaleQuestionConfig, SurveyQuestionKind, TextQuestionAnswer, + TextQuestionConfig, assertCastOrUndefined, } from '@llm-mediation-experiments/utils'; import { Observable, map } from 'rxjs'; @@ -176,18 +180,36 @@ export const localStorageTimer = ( // FORM BUILDER // // ********************************************************************************************* // -export const buildTextQuestionForm = (fb: FormBuilder, answer?: TextQuestionAnswer) => +export const buildTextQuestionForm = ( + fb: FormBuilder, + config: TextQuestionConfig, + answer?: TextQuestionAnswer, +) => fb.group({ + kind: SurveyQuestionKind.Text, + id: config.id, answerText: [answer?.answerText ?? '', Validators.required], }); -export const buildCheckQuestionForm = (fb: FormBuilder, answer?: CheckQuestionAnswer) => +export const buildCheckQuestionForm = ( + fb: FormBuilder, + config: CheckQuestionConfig, + answer?: CheckQuestionAnswer, +) => fb.group({ + kind: SurveyQuestionKind.Check, + id: config.id, checkMark: [answer?.checkMark ?? false], }); -export const buildRatingQuestionForm = (fb: FormBuilder, answer?: RatingQuestionAnswer) => +export const buildRatingQuestionForm = ( + fb: FormBuilder, + config: RatingQuestionConfig, + answer?: RatingQuestionAnswer, +) => fb.group({ + kind: SurveyQuestionKind.Rating, + id: config.id, choice: [answer?.choice, Validators.required], confidence: [ answer?.confidence ?? 0, @@ -195,8 +217,14 @@ export const buildRatingQuestionForm = (fb: FormBuilder, answer?: RatingQuestion ], }); -export const buildScaleQuestionForm = (fb: FormBuilder, answer?: ScaleQuestionAnswer) => +export const buildScaleQuestionForm = ( + fb: FormBuilder, + config: ScaleQuestionConfig, + answer?: ScaleQuestionAnswer, +) => fb.group({ + kind: SurveyQuestionKind.Scale, + id: config.id, score: [answer?.score ?? 0, [Validators.required, Validators.min(0), Validators.max(10)]], }); @@ -207,13 +235,29 @@ export const buildQuestionForm = ( ) => { switch (config.kind) { case SurveyQuestionKind.Text: - return buildTextQuestionForm(fb, assertCastOrUndefined(answer, SurveyQuestionKind.Text)); + return buildTextQuestionForm( + fb, + config, + assertCastOrUndefined(answer, SurveyQuestionKind.Text), + ); case SurveyQuestionKind.Check: - return buildCheckQuestionForm(fb, assertCastOrUndefined(answer, SurveyQuestionKind.Check)); + return buildCheckQuestionForm( + fb, + config, + assertCastOrUndefined(answer, SurveyQuestionKind.Check), + ); case SurveyQuestionKind.Rating: - return buildRatingQuestionForm(fb, assertCastOrUndefined(answer, SurveyQuestionKind.Rating)); + return buildRatingQuestionForm( + fb, + config, + assertCastOrUndefined(answer, SurveyQuestionKind.Rating), + ); case SurveyQuestionKind.Scale: - return buildScaleQuestionForm(fb, assertCastOrUndefined(answer, SurveyQuestionKind.Scale)); + return buildScaleQuestionForm( + fb, + config, + assertCastOrUndefined(answer, SurveyQuestionKind.Scale), + ); } }; From dd95a0879dbe22d83bb9ce48cc4a603983532fa1 Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Thu, 16 May 2024 20:18:37 +0200 Subject: [PATCH 28/35] use async --- .../experiment-monitor/experiment-monitor.component.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts b/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts index cd3b9599..6c7b13b8 100644 --- a/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts +++ b/webapp/src/app/experimenter-view/experiment-monitor/experiment-monitor.component.ts @@ -75,13 +75,11 @@ export class ExperimentMonitorComponent { ); } - deleteExperimentAndNavigate() { + async deleteExperimentAndNavigate() { const experiment = this.experiment(); if (experiment && confirm('⚠️ This will delete the experiment! Are you sure?')) { - experiment.delete().then(() => { - // Redirect to settings page. - this.router.navigate(['/experimenter', 'settings']); - }); + await experiment.delete(); + await this.router.navigate(['/experimenter', 'settings']); // Redirect to settings page. } } } From e1dded0470378a2dec46b132445d1182ce6957a5 Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Fri, 17 May 2024 17:38:55 +0200 Subject: [PATCH 29/35] use tsup to build the shaed utils package in order to avoid esm/cjs pain --- README.md | 4 +- functions/package-lock.json | 4 + functions/src/index.ts | 1 - utils/package-lock.json | 1966 ++++++++++++++++++++++++++++++++++- utils/package.json | 11 +- utils/tsconfig.json | 15 +- utils/tsup.config.ts | 13 + webapp/package-lock.json | 14 +- webapp/package.json | 1 + 9 files changed, 1988 insertions(+), 41 deletions(-) create mode 100644 utils/tsup.config.ts diff --git a/README.md b/README.md index 328edfe5..2ff877da 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,6 @@ This is a repository to support collaboration on using LLMs in behavioral econom ## Shared Utilities -The webapp, cloud functions, and seeding scripts share some utilities. These are located in the [`utils`](./utils) directory. - To build the shared utilities and watch for changes, run the following command: ```bash @@ -57,6 +55,8 @@ cd utils npm run build:watch ``` +The shared utilities are built using [`tsup`](https://tsup.egoist.dev) to produce both esm (for the webapp) and cjs (for the cloud functions and scripts) code. + ## Firebase This project uses Firebase as its backend. The configuration can be found in the [`.firebaserc`](./.firebaserc) and [`firebase.json`](./firebase.json) files. diff --git a/functions/package-lock.json b/functions/package-lock.json index f19b9964..17e0e242 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -30,6 +30,10 @@ "../utils": { "name": "@llm-mediation-experiments/utils", "version": "1.0.0", + "devDependencies": { + "tsup": "^8.0.2", + "typescript": "^5.4.5" + }, "peerDependencies": { "@sinclair/typebox": "^0.32.30", "firebase": "^10.11.1" diff --git a/functions/src/index.ts b/functions/src/index.ts index 34cd7e2c..f8f32027 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -4,7 +4,6 @@ export * from './endpoints/experiments.endpoints'; export * from './endpoints/messages.endpoints'; export * from './endpoints/participants.endpoints'; -export * from './endpoints/templates.endpoints'; // Firestore triggers export * from './triggers/chats.triggers'; diff --git a/utils/package-lock.json b/utils/package-lock.json index aacecac0..2efe2049 100644 --- a/utils/package-lock.json +++ b/utils/package-lock.json @@ -7,11 +7,383 @@ "": { "name": "@llm-mediation-experiments/utils", "version": "1.0.0", + "devDependencies": { + "tsup": "^8.0.2", + "typescript": "^5.4.5" + }, "peerDependencies": { "@sinclair/typebox": "^0.32.30", "firebase": "^10.11.1" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -579,6 +951,195 @@ "node": ">=6" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -643,12 +1204,226 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "peer": true }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz", + "integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz", + "integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz", + "integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz", + "integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz", + "integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz", + "integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz", + "integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz", + "integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz", + "integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz", + "integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz", + "integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz", + "integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz", + "integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz", + "integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz", + "integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz", + "integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sinclair/typebox": { "version": "0.32.30", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.32.30.tgz", "integrity": "sha512-IYK1H0k2sHVB2GjzBK2DXBErhex45GoLuPdgn8lNw5t0+5elIuhpixOMPobFyq6kE0AGIBa4+76Ph4enco0q2Q==", "peer": true }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, "node_modules/@types/node": { "version": "20.12.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.8.tgz", @@ -662,7 +1437,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "peer": true, "engines": { "node": ">=8" } @@ -671,7 +1445,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -682,43 +1455,251 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "peer": true, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">=12" + "node": ">= 8" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "peer": true, - "dependencies": { - "color-name": "~1.1.4" - }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, "engines": { - "node": ">=7.0.0" + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bundle-require": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-4.1.0.tgz", + "integrity": "sha512-FeArRFM+ziGkRViKRnSTbHZc35dgmR9yNog05Kn0+ItI59pOAISGvnnIwW1WgFZQW59IxD9QpJnUPkdIPfZuXg==", + "dev": true, + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.17" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "peer": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "peer": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } }, "node_modules/escalade": { "version": "3.1.2", @@ -729,6 +1710,54 @@ "node": ">=6" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -741,6 +1770,18 @@ "node": ">=0.8.0" } }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/firebase": { "version": "10.11.1", "resolved": "https://registry.npmjs.org/firebase/-/firebase-10.11.1.tgz", @@ -775,6 +1816,48 @@ "@firebase/util": "1.9.5" } }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -784,39 +1867,464 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.3.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz", + "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.11.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/http-parser-js": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", "peer": true }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "peer": true }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "peer": true, "engines": { "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", + "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "peer": true }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", "peer": true }, + "node_modules/lru-cache": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.1.tgz", + "integrity": "sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, "node_modules/protobufjs": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.6.tgz", @@ -841,6 +2349,47 @@ "node": ">=12.0.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -850,6 +2399,83 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz", + "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.17.2", + "@rollup/rollup-android-arm64": "4.17.2", + "@rollup/rollup-darwin-arm64": "4.17.2", + "@rollup/rollup-darwin-x64": "4.17.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.17.2", + "@rollup/rollup-linux-arm-musleabihf": "4.17.2", + "@rollup/rollup-linux-arm64-gnu": "4.17.2", + "@rollup/rollup-linux-arm64-musl": "4.17.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.17.2", + "@rollup/rollup-linux-riscv64-gnu": "4.17.2", + "@rollup/rollup-linux-s390x-gnu": "4.17.2", + "@rollup/rollup-linux-x64-gnu": "4.17.2", + "@rollup/rollup-linux-x64-musl": "4.17.2", + "@rollup/rollup-win32-arm64-msvc": "4.17.2", + "@rollup/rollup-win32-ia32-msvc": "4.17.2", + "@rollup/rollup-win32-x64-msvc": "4.17.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -870,11 +2496,73 @@ ], "peer": true }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "dev": true, + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -888,7 +2576,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -896,12 +2583,175 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "peer": true }, + "node_modules/tsup": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.0.2.tgz", + "integrity": "sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==", + "dev": true, + "dependencies": { + "bundle-require": "^4.0.0", + "cac": "^6.7.12", + "chokidar": "^3.5.1", + "debug": "^4.3.1", + "esbuild": "^0.19.2", + "execa": "^5.0.0", + "globby": "^11.0.3", + "joycon": "^3.0.1", + "postcss-load-config": "^4.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.0.2", + "source-map": "0.8.0-beta.0", + "sucrase": "^3.20.3", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "5.28.4", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", @@ -920,6 +2770,12 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "peer": true }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -943,6 +2799,32 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -960,6 +2842,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -969,6 +2869,18 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz", + "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/utils/package.json b/utils/package.json index 39a5405a..39b705cf 100644 --- a/utils/package.json +++ b/utils/package.json @@ -1,12 +1,13 @@ { "name": "@llm-mediation-experiments/utils", "version": "1.0.0", - "description": "", + "description": "Shared utilities for frontend and backend", "main": "dist/index.js", + "module": "dist/index.mjs", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "build:watch": "tsc -w" + "build": "tsup", + "build:watch": "tsup --watch" }, "files": [ "dist/**/*" @@ -14,5 +15,9 @@ "peerDependencies": { "@sinclair/typebox": "^0.32.30", "firebase": "^10.11.1" + }, + "devDependencies": { + "tsup": "^8.0.2", + "typescript": "^5.4.5" } } diff --git a/utils/tsconfig.json b/utils/tsconfig.json index 811d90b4..f29595ef 100644 --- a/utils/tsconfig.json +++ b/utils/tsconfig.json @@ -1,12 +1,19 @@ { "compilerOptions": { + "target": "ESNext", + "module": "ESNext", "outDir": "./dist", - "module": "commonjs", + "rootDir": "./src", + "moduleResolution": "node", "declaration": true, - "preserveConstEnums": true, - "strict": true, + "declarationMap": true, "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "preserveConstEnums": true, "lib": ["ESNext", "dom"] }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } diff --git a/utils/tsup.config.ts b/utils/tsup.config.ts new file mode 100644 index 00000000..1d8d1ec6 --- /dev/null +++ b/utils/tsup.config.ts @@ -0,0 +1,13 @@ +/** + * Tsup compilation config. Produce both esm and cjs outputs. + */ + +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], // Specify both cjs and esm formats + dts: true, // Generate TypeScript declaration files + sourcemap: true, // Generate source maps + clean: true, // Clean the output directory before each build +}); diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 42710597..48d74d47 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -19,6 +19,7 @@ "@angular/platform-browser-dynamic": "^17.0.6", "@angular/router": "^17.0.6", "@llm-mediation-experiments/utils": "file:../utils", + "@sinclair/typebox": "^0.32.30", "firebase": "^10.11.0", "lodash": "^4.17.21", "rxjs": "~7.8.0", @@ -4206,6 +4207,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/schemas/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -5730,10 +5737,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true + "version": "0.32.30", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.32.30.tgz", + "integrity": "sha512-IYK1H0k2sHVB2GjzBK2DXBErhex45GoLuPdgn8lNw5t0+5elIuhpixOMPobFyq6kE0AGIBa4+76Ph4enco0q2Q==" }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", diff --git a/webapp/package.json b/webapp/package.json index e79bfb0f..7c0ceed2 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -22,6 +22,7 @@ "@angular/platform-browser-dynamic": "^17.0.6", "@angular/router": "^17.0.6", "@llm-mediation-experiments/utils": "file:../utils", + "@sinclair/typebox": "^0.32.30", "firebase": "^10.11.0", "lodash": "^4.17.21", "rxjs": "~7.8.0", From a2d87d40f67d347ba6a1f203f9c7f02fba0c791d Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Tue, 21 May 2024 13:02:11 +0200 Subject: [PATCH 30/35] some fixes to make the app work completely --- firestore/firestore.rules | 13 ++-- .../src/endpoints/experiments.endpoints.ts | 17 +++++ .../src/endpoints/participants.endpoints.ts | 2 +- functions/src/triggers/chats.triggers.ts | 2 +- functions/src/triggers/stages.triggers.ts | 2 +- utils/package.json | 5 +- .../src/validation/experiments.validation.ts | 13 ++++ webapp/package.json | 2 +- .../create-experiment.component.html | 6 +- .../create-experiment.component.ts | 7 +- .../exp-chat/exp-chat.component.scss | 5 ++ .../exp-chat/exp-chat.component.ts | 76 +++++++++++++------ .../exp-leader-reveal.component.html | 3 +- .../exp-leader-reveal.component.ts | 11 ++- .../exp-survey/exp-survey.component.ts | 23 ++++-- .../survey-rating-question.component.html | 4 +- webapp/src/lib/api/callables.ts | 6 ++ .../src/lib/repositories/chat.repository.ts | 6 +- .../lib/repositories/experiment.repository.ts | 5 +- webapp/src/lib/utils/angular.utils.ts | 21 +---- 20 files changed, 156 insertions(+), 73 deletions(-) diff --git a/firestore/firestore.rules b/firestore/firestore.rules index 7378d65e..5bd09175 100644 --- a/firestore/firestore.rules +++ b/firestore/firestore.rules @@ -27,10 +27,10 @@ service cloud.firestore { // Validate the profile function validateProfile(value) { return value.keys().hasOnly(['pronouns', 'avatarUrl', 'name', 'acceptTosTimestamp']) && - (!value.pronouns || validateString(value.pronouns)) && - (!value.avatarUrl || validateString(value.avatarUrl)) && - (!value.name || validateString(value.name)) && - (!value.acceptTosTimestamp || validateTimestamp(value.acceptTosTimestamp)); + (!('pronouns' in value) || validateString(value.pronouns)) && + (!('avatarUrl' in value) || validateString(value.avatarUrl)) && + (!('name' in value) || validateString(value.name)) && + (!('acceptTosTimestamp' in value) || validateTimestamp(value.acceptTosTimestamp)); } // Validate the chat document data @@ -53,6 +53,7 @@ service cloud.firestore { allow get: if true; allow list: if request.auth.token.role == 'experimenter'; + allow delete: if request.auth.token.role == 'experimenter'; allow write: if false; // Complex validation through cloud functions match /stages/{stageId} { @@ -70,7 +71,7 @@ service cloud.firestore { allow get: if true; // Public if you know the ID allow list: if request.auth.token.role == 'experimenter'; // Avoid leaking IDs (only experimenters can view them) - allow update: if validateProfile(request.resource.data); + allow update: if true; // validateProfile(request.resource.data); // emulator bug match /stages/{stageId} { allow read: if true; @@ -79,7 +80,7 @@ service cloud.firestore { match /chats/{chatId} { allow read: if true; - allow update: if validateChat(request.resource.data); + allow update: if true; // validateChat(request.resource.data); // emulator bug match /messages/{messageId} { allow read: if true; diff --git a/functions/src/endpoints/experiments.endpoints.ts b/functions/src/endpoints/experiments.endpoints.ts index a46e6800..a342b552 100644 --- a/functions/src/endpoints/experiments.endpoints.ts +++ b/functions/src/endpoints/experiments.endpoints.ts @@ -3,6 +3,7 @@ import { ChatAnswer, ExperimentCreationData, + ExperimentDeletionData, GroupChatStageConfig, ParticipantProfile, StageKind, @@ -85,3 +86,19 @@ export const createExperiment = onCall(async (request) => { throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); }); + +/** Generic endpoint to recursively delete either experiments or experiment templates. + * Recursive deletion is only supported server-side. + */ +export const deleteExperiment = onCall(async (request) => { + await AuthGuard.isExperimenter(request); + + const { data } = request; + + if (Value.Check(ExperimentDeletionData, data)) { + const doc = app.firestore().doc(`${data.type}/${data.id}`); + app.firestore().recursiveDelete(doc); + } + + throw new functions.https.HttpsError('invalid-argument', 'Invalid data'); +}); diff --git a/functions/src/endpoints/participants.endpoints.ts b/functions/src/endpoints/participants.endpoints.ts index 636ef703..d005d21c 100644 --- a/functions/src/endpoints/participants.endpoints.ts +++ b/functions/src/endpoints/participants.endpoints.ts @@ -29,7 +29,7 @@ export const updateStage = onCall(async (request) => { case StageKind.VoteForLeader: if (participantId in stage.votes) throw new functions.https.HttpsError('invalid-argument', 'Invalid answers'); - await answerDoc.set({ votes: stage.votes }, { merge: true }); + await answerDoc.set({ kind: StageKind.VoteForLeader, votes: stage.votes }, { merge: true }); break; case StageKind.TakeSurvey: diff --git a/functions/src/triggers/chats.triggers.ts b/functions/src/triggers/chats.triggers.ts index 0fca438f..d2c3a056 100644 --- a/functions/src/triggers/chats.triggers.ts +++ b/functions/src/triggers/chats.triggers.ts @@ -24,7 +24,7 @@ export const publishParticipantReadyToEndChat = onDocumentWritten( // If the chat is a chat about items, increment the current item index const docData = (await publicChatData.get()).data(); - if (docData && Object.values(docData['readyToEndChat']).every((bool) => !bool)) { + if (docData && Object.values(docData['readyToEndChat']).every((ready) => ready)) { // Everyone is ready to end the chat if (docData['chatData'].kind === ChatKind.ChatAboutItems) { // Increment the current item index diff --git a/functions/src/triggers/stages.triggers.ts b/functions/src/triggers/stages.triggers.ts index ace65998..88ff22e6 100644 --- a/functions/src/triggers/stages.triggers.ts +++ b/functions/src/triggers/stages.triggers.ts @@ -63,7 +63,7 @@ export const initializePublicStageData = onDocumentWritten( /** When a participant updates stage answers, publish the answers to */ export const publishStageData = onDocumentWritten( - 'experiment/{experimentId}/participants/{participantId}/stages/{stageName}', + 'experiments/{experimentId}/participants/{participantId}/stages/{stageName}', async (event) => { const data = event.data?.after.data() as StageAnswer | undefined; if (!data) return; diff --git a/utils/package.json b/utils/package.json index 39b705cf..79f7f1ff 100644 --- a/utils/package.json +++ b/utils/package.json @@ -6,8 +6,9 @@ "module": "dist/index.mjs", "types": "dist/index.d.ts", "scripts": { - "build": "tsup", - "build:watch": "tsup --watch" + "declarations": "tsc --declaration --emitDeclarationOnly", + "build": "tsup --onSuccess \"npm run declarations\"", + "build:watch": "tsup --watch --onSuccess \"npm run declarations\"" }, "files": [ "dist/**/*" diff --git a/utils/src/validation/experiments.validation.ts b/utils/src/validation/experiments.validation.ts index 7700fc26..a51bbd7f 100644 --- a/utils/src/validation/experiments.validation.ts +++ b/utils/src/validation/experiments.validation.ts @@ -12,6 +12,19 @@ import { /** Shorthand for strict TypeBox object validation */ const strict = { additionalProperties: false } as const; +/** Generic experiment or template deletion data */ +export const ExperimentDeletionData = Type.Object( + { + // Discriminate between experiment and template + type: Type.Union([Type.Literal('experiments'), Type.Literal('templates')]), + + id: Type.String({ minLength: 1 }), + }, + strict, +); + +export type ExperimentDeletionData = Static; + /** * Generic experiment or template creation data */ diff --git a/webapp/package.json b/webapp/package.json index 7c0ceed2..c89e1c3b 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "scripts": { "ng": "ng", - "start": "ng serve", + "start": "ng serve --poll 1000", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", diff --git a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.html b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.html index 9a5463fc..9984f1c6 100644 --- a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.html +++ b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.html @@ -40,7 +40,11 @@

Create an experiment:

Template - + @for (template of templates(); track template.id) { {{ template.name }} } diff --git a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts index 30ec204b..5fd464a6 100644 --- a/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts +++ b/webapp/src/app/experimenter-view/create-experiment/create-experiment.component.ts @@ -8,7 +8,7 @@ import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; +import { MatSelectChange, MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -127,6 +127,11 @@ export class CreateExperimentComponent { this.currentEditingStageIndex = 0; } + /** Callback for template selection change */ + selectTemplate({ value }: MatSelectChange) { + this.currentTemplateChoice.set(value); + } + get currentEditingStage(): StageConfig | undefined { const stage = this.existingStages[this.currentEditingStageIndex]; diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.scss b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.scss index 25110b03..d54e5a20 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.scss +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.scss @@ -98,3 +98,8 @@ } } } + +.messages { + max-height: 25em; + overflow: auto; +} diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts index 38c88235..b1fcdf3f 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts @@ -6,7 +6,16 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { Component, Input, Signal, WritableSignal, computed, signal } from '@angular/core'; +import { + Component, + EnvironmentInjector, + Input, + Signal, + computed, + effect, + runInInjectionContext, + signal, +} from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -26,7 +35,7 @@ import { import { AppStateService } from 'src/app/services/app-state.service'; import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; import { ChatRepository } from 'src/lib/repositories/chat.repository'; -import { localStorageTimer, subscribeSignal, subscribeSignals } from 'src/lib/utils/angular.utils'; +import { localStorageTimer } from 'src/lib/utils/angular.utils'; import { ChatDiscussItemsMessageComponent } from './chat-discuss-items-message/chat-discuss-items-message.component'; import { ChatMediatorMessageComponent } from './chat-mediator-message/chat-mediator-message.component'; import { ChatUserMessageComponent } from './chat-user-message/chat-user-message.component'; @@ -67,28 +76,45 @@ export class ExpChatComponent { this.participantService.experiment()!.everyoneReachedStage(this.stage.config().name)(), ); - // On config change, extract the relevant chat repository - subscribeSignal(this.stage.config, (config) => { - // Extract the relevant chat repository for this chat - this.chat = this.appState.chats.get({ - chatId: config.chatId, - experimentId: this.participantService.experimentId()!, - participantId: this.participantService.participantId()!, + runInInjectionContext(this.injector, () => { + // On config change, extract the relevant chat repository and recompute signals + effect(() => { + const config = this.stage.config(); + + // Extract the relevant chat repository for this chat + this.chat = this.appState.chats.get({ + chatId: config.chatId, + experimentId: this.participantService.experimentId()!, + participantId: this.participantService.participantId()!, + }); + + this.readyToEndChat = computed(() => this.chat!.chat()?.readyToEndChat ?? false); + this.currentRatingsIndex = computed(() => { + return this.stage.public!().chatData.currentRatingIndex ?? 0; + }); + + // Initialize the current rating to discuss with the first available pair + const { item1, item2 } = config.chatConfig.ratingsToDiscuss[0]; + this.currentRatingsToDiscuss = signal({ item1, item2 }); + this.currentRatingsToDiscuss = computed( + () => config.chatConfig.ratingsToDiscuss[this.currentRatingsIndex()], + ); }); - this.readyToEndChat = computed(() => this.chat?.chat()?.readyToEndChat ?? false); + effect(() => { + this.currentRatingsIndex(); // Trigger reactivity when the currentRatingsIndex changes + this.chat?.markReadyToEndChat(false); // Reset readyToEndChat when the items to discuss change + }); - // Initialize the current rating to discuss with the first available pair - const { item1, item2 } = config.chatConfig.ratingsToDiscuss[0]; - this.currentRatingsToDiscuss = signal({ item1, item2 }); + if (this.participantService.workingOnStageName() === this.stage.config().name) { + // Automatic next step progression when the chat has ended + effect(() => { + const config = this.stage.config(); + const pub = this.stage.public!(); + if (chatReadyToEnd(config, pub)) this.nextStep(); + }); + } }); - - // Automatic next step progression when the chat has ended - if (this.participantService.workingOnStageName() === this.stage.config().name) { - subscribeSignals([this.stage.config, this.stage.public!], (config, pub) => { - if (chatReadyToEnd(config, pub)) this.nextStep(); - }); - } } get stage() { return this._stage as CastViewingStage; @@ -97,8 +123,9 @@ export class ExpChatComponent { public everyoneReachedTheChat: Signal; public readyToEndChat: Signal = signal(false); - // Extracted stage data (needed ?) - public currentRatingsToDiscuss: WritableSignal; + // Extracted stage data + public currentRatingsIndex: Signal; + public currentRatingsToDiscuss: Signal; // Message mutation & form public message = new FormControl('', Validators.required); @@ -109,9 +136,11 @@ export class ExpChatComponent { constructor( private appState: AppStateService, public participantService: ParticipantService, + private injector: EnvironmentInjector, ) { // Extract stage data this.everyoneReachedTheChat = signal(false); + this.currentRatingsIndex = signal(0); this.currentRatingsToDiscuss = signal(getDefaultItemPair()); } @@ -122,7 +151,6 @@ export class ExpChatComponent { async sendMessage() { if (!this.message.valid || !this.message.value) return; - this.chat?.sendUserMessage(this.message.value); this.message.setValue(''); } @@ -130,7 +158,7 @@ export class ExpChatComponent { toggleEndChat() { if (this.readyToEndChat()) return; - this.chat?.markReadyToEndChat(); + this.chat?.markReadyToEndChat(true); this.message.disable(); this.timer.remove(); diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html index 9568c66a..24c85c5e 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html @@ -5,7 +5,8 @@
} @else {
- The leader is {{ results()?.currentLeader }} + The leader is +
} diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts index a2aee62c..4ae1ec4d 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts @@ -2,15 +2,17 @@ import { Component, computed, Input, signal, Signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { assertCast, + ParticipantProfile, StageKind, VoteForLeaderStagePublicData, } from '@llm-mediation-experiments/utils'; import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; +import { ChatUserProfileComponent } from '../exp-chat/chat-user-profile/chat-user-profile.component'; @Component({ selector: 'app-exp-leader-reveal', standalone: true, - imports: [MatButtonModule], + imports: [MatButtonModule, ChatUserProfileComponent], templateUrl: './exp-leader-reveal.component.html', styleUrl: './exp-leader-reveal.component.scss', }) @@ -33,6 +35,12 @@ export class ExpLeaderRevealComponent { StageKind.VoteForLeader, ), ); + + this.winner = computed(() => { + return this.participantService.experiment()?.experiment()?.participants[ + this.results()!.currentLeader! + ]; + }); } get stage() { @@ -43,6 +51,7 @@ export class ExpLeaderRevealComponent { public everyoneReachedThisStage: Signal; public results: Signal = signal(undefined); + public winner: Signal = signal(undefined); constructor(private participantService: ParticipantService) { this.everyoneReachedThisStage = signal(false); diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts index 40794818..dbdd132a 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts @@ -6,7 +6,13 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { Component, Input, signal } from '@angular/core'; +import { + Component, + EnvironmentInjector, + Input, + effect, + runInInjectionContext, +} from '@angular/core'; import { FormArray, FormBuilder, @@ -21,7 +27,7 @@ import { MatSliderModule } from '@angular/material/slider'; import { MatButtonModule } from '@angular/material/button'; import { StageKind, SurveyQuestionKind, assertCast } from '@llm-mediation-experiments/utils'; import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; -import { buildQuestionForm, subscribeSignals } from 'src/lib/utils/angular.utils'; +import { buildQuestionForm } from 'src/lib/utils/angular.utils'; import { SurveyCheckQuestionComponent } from './survey-check-question/survey-check-question.component'; import { SurveyRatingQuestionComponent } from './survey-rating-question/survey-rating-question.component'; import { SurveyScaleQuestionComponent } from './survey-scale-question/survey-scale-question.component'; @@ -52,9 +58,11 @@ export class ExpSurveyComponent { this._stage = value; // Regenerate the questions everytime the stage config or answers change - subscribeSignals( - [this.stage.config, this.stage.answers ?? signal(undefined)], - ({ questions }, answers) => { + runInInjectionContext(this.injector, () => { + effect(() => { + const { questions } = this.stage.config(); + const answers = this.stage.answers?.(); + this.answers.clear(); questions.forEach((config) => { const answer = answers?.answers[config.id]; @@ -62,8 +70,8 @@ export class ExpSurveyComponent { // The answer, if defined, will be used to populate the form this.answers.push(buildQuestionForm(this.fb, config, answer)); }); - }, - ); + }); + }); } get stage() { @@ -81,6 +89,7 @@ export class ExpSurveyComponent { constructor( private fb: FormBuilder, public participantService: ParticipantService, + private injector: EnvironmentInjector, ) { this.answers = fb.array([]); this.surveyForm = fb.group({ diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html index eaf84466..fbe5715f 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html @@ -4,14 +4,14 @@ {{ ITEMS[question.item1].name }} {{ ITEMS[question.item1].name }} {{ ITEMS[question.item2].name }} {{ ITEMS[question.item2].name }} diff --git a/webapp/src/lib/api/callables.ts b/webapp/src/lib/api/callables.ts index 52836617..c5ba5b2f 100644 --- a/webapp/src/lib/api/callables.ts +++ b/webapp/src/lib/api/callables.ts @@ -3,6 +3,7 @@ import { CreationResponse, ExperimentCreationData, + ExperimentDeletionData, MessageData, SimpleResponse, StageAnswerData, @@ -30,3 +31,8 @@ export const createExperimentCallable = data( export const updateStageCallable = data( httpsCallable>(functions, 'updateStage'), ); + +/** Generic endpoint to delete experiments or experiment templates */ +export const deleteExperimentCallable = data( + httpsCallable(functions, 'deleteExperiment'), +); diff --git a/webapp/src/lib/repositories/chat.repository.ts b/webapp/src/lib/repositories/chat.repository.ts index 1af9ca96..bcd07430 100644 --- a/webapp/src/lib/repositories/chat.repository.ts +++ b/webapp/src/lib/repositories/chat.repository.ts @@ -60,7 +60,7 @@ export class ChatRepository extends BaseRepository { ), (snapshot) => { // Note that Firestore will send incremental updates. The full list of messages can be reconstructed easily from the snapshot. - this._messages.set(collectSnapshotWithId(snapshot, 'uid')); + this._messages.set(collectSnapshotWithId(snapshot, 'uid').reverse()); }, ), ); @@ -73,7 +73,7 @@ export class ChatRepository extends BaseRepository { /** Mark this participant as ready to end the chat, or ready to discuss about the next pair of items. * @rights Participant */ - async markReadyToEndChat() { + async markReadyToEndChat(readyToEndChat: boolean) { return updateDoc( doc( firestore, @@ -85,7 +85,7 @@ export class ChatRepository extends BaseRepository { this.chatId, ), { - readyToEndChat: true, + readyToEndChat, }, ); } diff --git a/webapp/src/lib/repositories/experiment.repository.ts b/webapp/src/lib/repositories/experiment.repository.ts index 472277db..33252118 100644 --- a/webapp/src/lib/repositories/experiment.repository.ts +++ b/webapp/src/lib/repositories/experiment.repository.ts @@ -1,6 +1,7 @@ import { Signal, WritableSignal, computed, signal } from '@angular/core'; import { Experiment, PublicStageData, StageConfig } from '@llm-mediation-experiments/utils'; -import { collection, deleteDoc, doc, onSnapshot } from 'firebase/firestore'; +import { collection, doc, onSnapshot } from 'firebase/firestore'; +import { deleteExperimentCallable } from '../api/callables'; import { firestore } from '../api/firebase'; import { BaseRepository } from './base.repository'; @@ -111,6 +112,6 @@ export class ExperimentRepository extends BaseRepository { * @rights Experimenter */ async delete() { - return deleteDoc(doc(firestore, 'experiments', this.uid)); + return deleteExperimentCallable({ type: 'experiments', id: this.uid }); } } diff --git a/webapp/src/lib/utils/angular.utils.ts b/webapp/src/lib/utils/angular.utils.ts index 90a222ba..f894e87d 100644 --- a/webapp/src/lib/utils/angular.utils.ts +++ b/webapp/src/lib/utils/angular.utils.ts @@ -1,6 +1,6 @@ /** Util functions to manipulate Angular constructs */ -import { Signal, WritableSignal, computed, effect, signal, untracked } from '@angular/core'; +import { Signal, WritableSignal, effect, signal, untracked } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl, @@ -103,28 +103,11 @@ export const assertSignalCast = (_signal: Signal, callback: (value: T) => void) => { toObservable(_signal).subscribe(callback); }; -/** Subscribe to a list of signal updates. This function does not rely on `effect()` and can be used outside of injection contexts */ -export const subscribeSignals = []>( - signals: [...T], - callback: (...args: [...UnwrappedSignalArrayType]) => void, -): void => { - const bundle = computed(() => signals.map((s) => s())); - toObservable(bundle).subscribe((args) => callback(...(args as [...UnwrappedSignalArrayType]))); -}; - -// Helper types for `subscribeSignals` function -/** `Signal` -> `T` */ -type UnwrappedSignalType = T extends Signal ? U : never; -/** `Signal[]` -> `[T]` */ -type UnwrappedSignalArrayType[]> = { - [K in keyof T]: UnwrappedSignalType; -}; - /** Creates a second-counter timer that is synchronized with the local storage in order to resume ticking when reloading the page */ export const localStorageTimer = ( key: string, From 613b86edb66bafee0c4b691e5f17c3232faacb1c Mon Sep 17 00:00:00 2001 From: Leo Laugier Date: Fri, 17 May 2024 16:09:46 +0200 Subject: [PATCH 31/35] replaced boilerplate items with actual items form the list --- functions/src/seeders/stages.seeder.ts | 36 +++++----- scripts/src/seed-database.ts | 14 ++-- utils/src/types/items.types.ts | 94 ++++++++++++++++++++++---- utils/src/types/questions.types.ts | 4 +- 4 files changed, 108 insertions(+), 40 deletions(-) diff --git a/functions/src/seeders/stages.seeder.ts b/functions/src/seeders/stages.seeder.ts index eca90f75..c0eff625 100644 --- a/functions/src/seeders/stages.seeder.ts +++ b/functions/src/seeders/stages.seeder.ts @@ -23,14 +23,13 @@ export class StagesSeeder { id: '1', questionText: 'Rate the items by how helpful they would be for survival.', item1: { - name: 'compas', - imageUrl: - 'https://m.media-amazon.com/images/I/81NUeKWdiQL._AC_UF1000,1000_QL80_.jpg', + name: 'shavingMirror', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/3/32/Mirror%2C_shaving_%28AM_880330-3%29.jpg', }, item2: { - name: 'blanket', - imageUrl: - 'https://img.freepik.com/free-psd/blanket-isolated-transparent-background_191095-10098.jpg?size=338&ext=jpg&ga=GA1.1.1700460183.1712448000&semt=sph', + name: 'sextant', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Sextant_von_Alexander_von_Humboldt.jpg/640px-Sextant_von_Alexander_von_Humboldt.jpg', }, choice: null, confidence: null, @@ -59,18 +58,18 @@ export class StagesSeeder { messages: [], items: [ { - name: 'compas', - imageUrl: 'https://m.media-amazon.com/images/I/81NUeKWdiQL._AC_UF1000,1000_QL80_.jpg', + name: 'shavingMirror', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/3/32/Mirror%2C_shaving_%28AM_880330-3%29.jpg', }, { - name: 'blanket', - imageUrl: - 'https://img.freepik.com/free-psd/blanket-isolated-transparent-background_191095-10098.jpg?size=338&ext=jpg&ga=GA1.1.1700460183.1712448000&semt=sph', + name: 'sextant', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Sextant_von_Alexander_von_Humboldt.jpg/640px-Sextant_von_Alexander_von_Humboldt.jpg', }, { - name: 'lighter', + name: 'mosquitoNetting', imageUrl: - 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c9/White_lighter_with_flame.JPG/1200px-White_lighter_with_flame.JPG', + 'https://commons.wikimedia.org/wiki/Category:Mosquito_nets#/media/File:Net,_mosquito_(AM_2015.20.7-1).jpg', }, ], readyToEndChat: false, @@ -121,14 +120,13 @@ export class StagesSeeder { id: '5', questionText: 'Please rating the following accoring to which is best for survival', item1: { - name: 'compas', - imageUrl: - 'https://m.media-amazon.com/images/I/81NUeKWdiQL._AC_UF1000,1000_QL80_.jpg', + name: 'shavingMirror', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/3/32/Mirror%2C_shaving_%28AM_880330-3%29.jpg', }, item2: { - name: 'blanket', - imageUrl: - 'https://img.freepik.com/free-psd/blanket-isolated-transparent-background_191095-10098.jpg?size=338&ext=jpg&ga=GA1.1.1700460183.1712448000&semt=sph', + name: 'sextant', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Sextant_von_Alexander_von_Humboldt.jpg/640px-Sextant_von_Alexander_von_Humboldt.jpg', }, choice: null, confidence: null, diff --git a/scripts/src/seed-database.ts b/scripts/src/seed-database.ts index e345e040..0ea19cc2 100644 --- a/scripts/src/seed-database.ts +++ b/scripts/src/seed-database.ts @@ -100,8 +100,8 @@ const DEFAULT_STAGES: Record = { id: 0, kind: SurveyQuestionKind.Rating, questionText: 'Rate the items by how helpful they would be for survival.', - item1: 'compas', - item2: 'blanket', + item1: 'sextant', + item2: 'shavingMirror', }, { id: 1, @@ -120,9 +120,9 @@ const DEFAULT_STAGES: Record = { chatConfig: { kind: ChatKind.ChatAboutItems, ratingsToDiscuss: [ - { item1: 'blanket', item2: 'compas' }, - { item1: 'blanket', item2: 'lighter' }, - { item1: 'lighter', item2: 'compas' }, + { item1: 'sextant', item2: 'shavingMirror' }, + { item1: 'sextant', item2: 'mosquitoNetting' }, + { item1: 'shavingMirror', item2: 'mosquitoNetting' }, ], }, }, @@ -169,8 +169,8 @@ const DEFAULT_STAGES: Record = { id: 0, kind: SurveyQuestionKind.Rating, questionText: 'Please rating the following accoring to which is best for survival', - item1: 'compas', - item2: 'blanket', + item1: 'sextant', + item2: 'shavingMirror', }, ], }, diff --git a/utils/src/types/items.types.ts b/utils/src/types/items.types.ts index a37fcbc3..0c9ee2b2 100644 --- a/utils/src/types/items.types.ts +++ b/utils/src/types/items.types.ts @@ -17,22 +17,92 @@ export type ItemChoice = keyof ItemPair; // ITEMS // // ********************************************************************************************* // -export const ITEM_NAMES = ['blanket', 'compas', 'lighter'] as const; +export const ITEM_NAMES = [ + 'sextant', + 'shavingMirror', + 'mosquitoNetting', + 'waterContainer', + 'armyRations', + 'pacificMaps', + 'floatingSeatCushion', + 'canOilMixture', + 'transistorRadio', + 'plasticSheeting', + 'sharkRepellent', + 'rubbingAlcohol', + 'nylonRope', + 'chocolateBars', + 'fishingKit', +] as const; export type ItemName = (typeof ITEM_NAMES)[number]; export const ITEMS: Record = { - blanket: { - name: 'blanket', + sextant: { + name: 'sextant', imageUrl: - 'https://img.freepik.com/free-psd/blanket-isolated-transparent-background_191095-10098.jpg?size=338&ext=jpg&ga=GA1.1.1700460183.1712448000&semt=sph', + 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Sextant_von_Alexander_von_Humboldt.jpg/640px-Sextant_von_Alexander_von_Humboldt.jpg', }, - compas: { - name: 'compas', - imageUrl: 'https://m.media-amazon.com/images/I/81NUeKWdiQL._AC_UF1000,1000_QL80_.jpg', + shavingMirror: { + name: 'shavingMirror', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/3/32/Mirror%2C_shaving_%28AM_880330-3%29.jpg', + }, + mosquitoNetting: { + name: 'mosquitoNetting', + imageUrl: + 'https://commons.wikimedia.org/wiki/Category:Mosquito_nets#/media/File:Net,_mosquito_(AM_2015.20.7-1).jpg', + }, + waterContainer: { + name: '25 liter container of Water', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/c/c4/PikiWiki_Israel_65236_container_for_water.jpg', + }, + armyRations: { + name: 'Case of Army Rations', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/0/00/24_Hour_Multi_Climate_Ration_Pack_MOD_45157289.jpg', + }, + pacificMaps: { + name: 'Maps of the Pacific Ocean', // ToDo - Change to Atlantic ocean? + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/d/d6/Kepler-world.jpg', + }, + floatingSeatCushion: { + name: 'Floating Seat Cushion', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/e/ec/EM_USAIRWAYS_EXPRESS_CRJ-200_%282878446162%29.jpg', + }, + canOilMixture: { + name: '10 liter can of Oil/Petrol mixture', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/3/33/Britische_copy_wehrmacht-einheitskanister_1943_jerrycan.jpg', }, - lighter: { - name: 'lighter', + transistorRadio: { + name: 'Small Transistor Radio', imageUrl: - 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c9/White_lighter_with_flame.JPG/1200px-White_lighter_with_flame.JPG', + 'https://upload.wikimedia.org/wikipedia/commons/b/b6/Vintage_Philco_6-Transistor_Radio%2C_Model_T76-124%2C_1958%2C_Leather_Case_%288385122630%29.jpg', + }, + plasticSheeting: { + name: 'Plastic Sheeting', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/1/13/Film_StandardForm001.jpg', + }, + sharkRepellent: { + name: 'Can of Shark Repellent', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/1/11/Konservendose-1.jpg', + }, + rubbingAlcohol: { + name: 'One bottle rubbing alcohol', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/b/b7/Rubbing_alcohol.JPG', + }, + nylonRope: { + name: '15 ft. of Nylon Rope', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/3/35/Nylon_Rope.JPG', + }, + chocolateBars: { + name: '2 boxes of Chocolate Bars', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/9/91/2Tablettes_Grand_crus.png', + }, + fishingKit: { + name: 'An ocean Fishing Kit & Pole', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/2/29/Fishing_time_at_sea.jpg', }, }; @@ -42,7 +112,7 @@ export const ITEMS: Record = { export const getDefaultItemPair = (): ItemPair => { return { - item1: 'blanket', - item2: 'compas', + item1: 'sextant', + item2: 'shavingMirror', }; }; diff --git a/utils/src/types/questions.types.ts b/utils/src/types/questions.types.ts index bc09c2ca..8ff1d945 100644 --- a/utils/src/types/questions.types.ts +++ b/utils/src/types/questions.types.ts @@ -112,8 +112,8 @@ export const getDefaultItemRatingsQuestion = (): RatingQuestionConfig => { id: 0, kind: SurveyQuestionKind.Rating, questionText: '', - item1: 'blanket', - item2: 'compas', + item1: 'sextant', + item2: 'shavingMirror', }; }; From 4d07c028d33dba6907791d44adaa21549aa5f153 Mon Sep 17 00:00:00 2001 From: Leo Laugier Date: Fri, 17 May 2024 17:56:30 +0200 Subject: [PATCH 32/35] seeding the database with 5 random pairs out of all possible pairs (no duplicates) --- functions/src/seeders/stages.seeder.ts | 4 +- scripts/package.json | 2 +- scripts/src/seed-database.ts | 67 +++++++++++++++++--------- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/functions/src/seeders/stages.seeder.ts b/functions/src/seeders/stages.seeder.ts index c0eff625..cb4cdb29 100644 --- a/functions/src/seeders/stages.seeder.ts +++ b/functions/src/seeders/stages.seeder.ts @@ -24,8 +24,8 @@ export class StagesSeeder { questionText: 'Rate the items by how helpful they would be for survival.', item1: { name: 'shavingMirror', - imageUrl: - 'https://upload.wikimedia.org/wikipedia/commons/3/32/Mirror%2C_shaving_%28AM_880330-3%29.jpg', + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/3/32/Mirror%2C_shaving_%28AM_880330-3%29.jpg', }, item2: { name: 'sextant', diff --git a/scripts/package.json b/scripts/package.json index 5e651ddd..42e36f08 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -13,4 +13,4 @@ "devDependencies": { "@types/node": "^20.12.7" } -} +} \ No newline at end of file diff --git a/scripts/src/seed-database.ts b/scripts/src/seed-database.ts index 0ea19cc2..5191d1c8 100644 --- a/scripts/src/seed-database.ts +++ b/scripts/src/seed-database.ts @@ -3,6 +3,9 @@ import { ChatKind, Experiment, ExperimentTemplate, + ITEM_NAMES, + ItemName, + QuestionConfig, StageConfig, StageKind, SurveyQuestionKind, @@ -14,6 +17,42 @@ import admin, { initializeApp } from './admin'; initializeApp(); +// TODO: bouger ça dans des utils, rassifier les types, et ammend le commit, puis push force with lease +// function to generate n random pairs from all the ITEMS +const getRandomPairs = (itemNames: readonly ItemName[], pairNum: number) => { + const itemPairs = []; + for (let i = 0; i < itemNames.length; i++) { + for (let j = i + 1; j < itemNames.length; j++) { + itemPairs.push([itemNames[i], itemNames[j]]); + } + } + + const randomPairs = []; + // no duplicates + while (randomPairs.length < pairNum) { + const randomIndex = Math.floor(Math.random() * itemPairs.length); + // randomly swap the first and second item in the pair + const randomPair = itemPairs[randomIndex]; + if (Math.random() > 0.5) { + itemPairs[randomIndex] = [itemPairs[randomIndex][1], itemPairs[randomIndex][0]]; + } + randomPairs.push(randomPair); + itemPairs.splice(randomIndex, 1); + } + + return randomPairs; +}; + +const randomPairs = getRandomPairs(ITEM_NAMES, 5); + +const ratingQuestions: QuestionConfig[] = randomPairs.map(([item1, item2], id) => ({ + id, + kind: SurveyQuestionKind.Rating, + questionText: 'Rate the items by how helpful they would be for survival.', + item1, + item2, +})); + const seedDatabase = async () => { const db = admin.firestore(); @@ -96,21 +135,15 @@ const DEFAULT_STAGES: Record = { name: '2. Initial leadership survey', kind: StageKind.TakeSurvey, questions: [ + ...ratingQuestions, { - id: 0, - kind: SurveyQuestionKind.Rating, - questionText: 'Rate the items by how helpful they would be for survival.', - item1: 'sextant', - item2: 'shavingMirror', - }, - { - id: 1, + id: 99, // Avoid collision with rating questions id (starting from 0) kind: SurveyQuestionKind.Scale, questionText: 'Rate the how much you would like to be the group leader.', lowerBound: 'I would most definitely not like to be the leader (0/10)', upperBound: 'I will fight to be the leader (10/10)', }, - ], + ] as QuestionConfig[], }, '3. Group discussion': { @@ -119,11 +152,7 @@ const DEFAULT_STAGES: Record = { chatId: 'chat-0', chatConfig: { kind: ChatKind.ChatAboutItems, - ratingsToDiscuss: [ - { item1: 'sextant', item2: 'shavingMirror' }, - { item1: 'sextant', item2: 'mosquitoNetting' }, - { item1: 'shavingMirror', item2: 'mosquitoNetting' }, - ], + ratingsToDiscuss: randomPairs.map(([i1, i2]) => ({ item1: i1, item2: i2 })), }, }, @@ -164,15 +193,7 @@ const DEFAULT_STAGES: Record = { '7. Post-discussion work': { name: '7. Post-discussion work', kind: StageKind.TakeSurvey, - questions: [ - { - id: 0, - kind: SurveyQuestionKind.Rating, - questionText: 'Please rating the following accoring to which is best for survival', - item1: 'sextant', - item2: 'shavingMirror', - }, - ], + questions: ratingQuestions, }, '8. Leader reveal': { From 51fd14c9bb4ca155bebc321f32303ef642f922a1 Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Tue, 21 May 2024 15:00:35 +0200 Subject: [PATCH 33/35] cleanup seeder script --- scripts/src/seed-database.ts | 61 +++++++++++---------------------- utils/src/index.ts | 1 + utils/src/utils/object.utils.ts | 13 +++++++ utils/src/utils/random.utils.ts | 55 +++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 41 deletions(-) create mode 100644 utils/src/utils/random.utils.ts diff --git a/scripts/src/seed-database.ts b/scripts/src/seed-database.ts index 5191d1c8..c440b7fb 100644 --- a/scripts/src/seed-database.ts +++ b/scripts/src/seed-database.ts @@ -4,54 +4,22 @@ import { Experiment, ExperimentTemplate, ITEM_NAMES, - ItemName, - QuestionConfig, + RatingQuestionConfig, StageConfig, StageKind, SurveyQuestionKind, + choices, getDefaultProfile, + pairs, participantPublicId, + seed, } from '@llm-mediation-experiments/utils'; import { Timestamp } from 'firebase-admin/firestore'; import admin, { initializeApp } from './admin'; initializeApp(); -// TODO: bouger ça dans des utils, rassifier les types, et ammend le commit, puis push force with lease -// function to generate n random pairs from all the ITEMS -const getRandomPairs = (itemNames: readonly ItemName[], pairNum: number) => { - const itemPairs = []; - for (let i = 0; i < itemNames.length; i++) { - for (let j = i + 1; j < itemNames.length; j++) { - itemPairs.push([itemNames[i], itemNames[j]]); - } - } - - const randomPairs = []; - // no duplicates - while (randomPairs.length < pairNum) { - const randomIndex = Math.floor(Math.random() * itemPairs.length); - // randomly swap the first and second item in the pair - const randomPair = itemPairs[randomIndex]; - if (Math.random() > 0.5) { - itemPairs[randomIndex] = [itemPairs[randomIndex][1], itemPairs[randomIndex][0]]; - } - randomPairs.push(randomPair); - itemPairs.splice(randomIndex, 1); - } - - return randomPairs; -}; - -const randomPairs = getRandomPairs(ITEM_NAMES, 5); - -const ratingQuestions: QuestionConfig[] = randomPairs.map(([item1, item2], id) => ({ - id, - kind: SurveyQuestionKind.Rating, - questionText: 'Rate the items by how helpful they would be for survival.', - item1, - item2, -})); +seed(585050400); // Seed the random number generator const seedDatabase = async () => { const db = admin.firestore(); @@ -119,6 +87,17 @@ const seedDatabase = async () => { // ********************************************************************************************* // // SEEDER DATA // // ********************************************************************************************* // +const RANDOM_ITEM_PAIRS = choices(pairs(ITEM_NAMES), 5); + +const RATING_QUESTION_CONFIGS: RatingQuestionConfig[] = RANDOM_ITEM_PAIRS.map( + ([item1, item2], id) => ({ + id, + kind: SurveyQuestionKind.Rating, + questionText: 'Rate the items by how helpful they would be for survival.', + item1, + item2, + }), +); const DEFAULT_STAGES: Record = { '1. Agree to the experiment and set your profile': { @@ -135,7 +114,7 @@ const DEFAULT_STAGES: Record = { name: '2. Initial leadership survey', kind: StageKind.TakeSurvey, questions: [ - ...ratingQuestions, + ...RATING_QUESTION_CONFIGS, { id: 99, // Avoid collision with rating questions id (starting from 0) kind: SurveyQuestionKind.Scale, @@ -143,7 +122,7 @@ const DEFAULT_STAGES: Record = { lowerBound: 'I would most definitely not like to be the leader (0/10)', upperBound: 'I will fight to be the leader (10/10)', }, - ] as QuestionConfig[], + ], }, '3. Group discussion': { @@ -152,7 +131,7 @@ const DEFAULT_STAGES: Record = { chatId: 'chat-0', chatConfig: { kind: ChatKind.ChatAboutItems, - ratingsToDiscuss: randomPairs.map(([i1, i2]) => ({ item1: i1, item2: i2 })), + ratingsToDiscuss: RANDOM_ITEM_PAIRS.map(([i1, i2]) => ({ item1: i1, item2: i2 })), }, }, @@ -193,7 +172,7 @@ const DEFAULT_STAGES: Record = { '7. Post-discussion work': { name: '7. Post-discussion work', kind: StageKind.TakeSurvey, - questions: ratingQuestions, + questions: RATING_QUESTION_CONFIGS, }, '8. Leader reveal': { diff --git a/utils/src/index.ts b/utils/src/index.ts index b4de6bae..a36a3779 100644 --- a/utils/src/index.ts +++ b/utils/src/index.ts @@ -15,6 +15,7 @@ export * from './types/votes.types'; export * from './utils/algebraic.utils'; export * from './utils/cache.utils'; export * from './utils/object.utils'; +export * from './utils/random.utils'; export * from './utils/string.utils'; // Validation (peer dependency: @sinclair/typebox) diff --git a/utils/src/utils/object.utils.ts b/utils/src/utils/object.utils.ts index caaba988..b613737d 100644 --- a/utils/src/utils/object.utils.ts +++ b/utils/src/utils/object.utils.ts @@ -88,3 +88,16 @@ export const mergeableRecord = (record: Record, fieldN return result; }; + +/** Returns a list of all possible pairs of array elements without duplicates, assuming the array elements are distinct */ +export const pairs = (array: readonly T[]): [T, T][] => { + const result: [T, T][] = []; + + for (let i = 0; i < array.length - 1; i++) { + for (let j = i + 1; j < array.length; j++) { + result.push([array[i], array[j]]); + } + } + + return result; +}; diff --git a/utils/src/utils/random.utils.ts b/utils/src/utils/random.utils.ts new file mode 100644 index 00000000..1e02f04a --- /dev/null +++ b/utils/src/utils/random.utils.ts @@ -0,0 +1,55 @@ +/** Random utilities that support seeding. */ + +// ********************************************************************************************* // +// SEED // +// ********************************************************************************************* // + +// Seed shared by all random functions. +let RANDOM_SEED = 0; + +/** Initialize the seed with a custom value */ +export const seed = (value: number) => { + RANDOM_SEED = value; +}; + +/** Update the seed using a Linear Congruential Generator */ +const next = (a = 1664525, b = 1013904223, m = 2 ** 32) => { + RANDOM_SEED = (a * RANDOM_SEED + b) % m; + return RANDOM_SEED; +}; + +// ********************************************************************************************* // +// RANDOM FUNCTIONS // +// ********************************************************************************************* // + +/** Returns a random floating point value in [0, 1] */ +export const random = () => next() / 2 ** 32; + +/** Returns a random integer in [min, max] */ +export const randint = (min: number, max: number) => Math.floor(random() * (max - min + 1)) + min; + +/** Chooses a random value from an array. The array is not modified. */ +export const choice = (array: readonly T[]): T => { + if (array.length === 0) { + throw new Error('Cannot choose from an empty array'); + } + + return array[randint(0, array.length - 1)]; +}; + +/** Chooses n random distinct values from an array. The array is not modified. */ +export const choices = (array: readonly T[], n: number): T[] => { + if (array.length <= n) { + throw new Error(`Cannot choose ${n} distinct values from an array of length ${array.length}`); + } + + const copy = [...array]; + const result: T[] = []; + for (let i = 0; i < n; i++) { + const index = randint(0, copy.length - 1); + result.push(copy[index]); + copy.splice(index, 1); + } + + return result; +}; From d6419291a7647bfe482c276db568c9d22f9a7e4a Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Tue, 21 May 2024 15:52:59 +0200 Subject: [PATCH 34/35] improve chat ui messages --- utils/src/types/items.types.ts | 6 +++--- .../exp-chat/exp-chat.component.html | 12 ++++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/utils/src/types/items.types.ts b/utils/src/types/items.types.ts index 0c9ee2b2..2e8c2a4c 100644 --- a/utils/src/types/items.types.ts +++ b/utils/src/types/items.types.ts @@ -37,17 +37,17 @@ export const ITEM_NAMES = [ export type ItemName = (typeof ITEM_NAMES)[number]; export const ITEMS: Record = { sextant: { - name: 'sextant', + name: 'Sextant', imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Sextant_von_Alexander_von_Humboldt.jpg/640px-Sextant_von_Alexander_von_Humboldt.jpg', }, shavingMirror: { - name: 'shavingMirror', + name: 'Shaving mirror', imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/3/32/Mirror%2C_shaving_%28AM_880330-3%29.jpg', }, mosquitoNetting: { - name: 'mosquitoNetting', + name: 'Mosquito netting', imageUrl: 'https://commons.wikimedia.org/wiki/Category:Mosquito_nets#/media/File:Net,_mosquito_(AM_2015.20.7-1).jpg', }, diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html index 1e202c51..f139247b 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html @@ -67,13 +67,21 @@ [disabled]="isSilent() || readyToEndChat()" (change)="toggleEndChat()" > - I'm done with chatting and ready to move on + @if (currentRatingsIndex() < stage.config().chatConfig.ratingsToDiscuss.length - 1) { + I'm done with chatting and ready to move on to the next pair of items + } @else { + I'm done with chatting and ready to move on to the next stage + }
@if (currentRatingsToDiscuss()) { - Your discussion is now focusing on selecting the best item between the following items + Your discussion is now focusing on selecting the best item between the following items ({{ + currentRatingsIndex() + 1 + }}/{{ stage.config().chatConfig.ratingsToDiscuss.length }})
From f97092b946da96985382e69f622139349ffa87b8 Mon Sep 17 00:00:00 2001 From: Thibaut de Saivre Date: Tue, 21 May 2024 16:06:11 +0200 Subject: [PATCH 35/35] fix chat timer --- .../participant-stage-view/exp-chat/exp-chat.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts index b1fcdf3f..0021eaeb 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts @@ -102,8 +102,11 @@ export class ExpChatComponent { }); effect(() => { + // Only if we are currently working on this stage + if (this.participantService.workingOnStageName() !== this.stage.config().name) return; this.currentRatingsIndex(); // Trigger reactivity when the currentRatingsIndex changes this.chat?.markReadyToEndChat(false); // Reset readyToEndChat when the items to discuss change + this.timer.reset(TIMER_SECONDS); // Reset the timer }); if (this.participantService.workingOnStageName() === this.stage.config().name) {