import { Inject, Injectable } from '@angular/core'; import { ActivatedRoute, ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { Answer } from '../enums/answer.enum'; import { Choice } from '../models/choice.model'; import { Poll } from '../models/poll.model'; import { ApiService } from './api.service'; import { ToastService } from './toast.service'; import { UserService } from './user.service'; import { UuidService } from './uuid.service'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../../environments/environment'; import { StorageService } from './storage.service'; import { Title } from '@angular/platform-browser'; import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { DOCUMENT } from '@angular/common'; import { DateChoice, TimeSlices } from '../models/dateChoice.model'; import { DateUtilitiesService } from './date.utilities.service'; import { Owner } from '../models/owner.model'; import { Stack } from '../models/stack.model'; import { Vote } from '../models/vote.model'; @Injectable({ providedIn: 'root', }) export class PollService implements Resolve { public _poll: BehaviorSubject = new BehaviorSubject(undefined); public readonly poll: Observable = this._poll.asObservable(); public form: FormGroup; public startDateInterval: string; public endDateInterval: string; public intervalDays: number = 1; public intervalDaysDefault = 7; public dateList: DateChoice[] = []; // sets of days as strings, config to set identical time for days in a special days poll public timeList: TimeSlices[] = []; // ranges of time expressed as strings public previousRouteName: string = '/administration'; public nextRouteName: string = '/administration/step/2'; public step_current: number = 1; public step_max: number = 5; public round: Function; public pass_hash: string; public admin_key: string; public urlPrefix: string = window.location.origin; public advancedDisplayEnabled = false; public showDateInterval = false; public allowSeveralHours = false; public richTextMode = false; public calendar: any; constructor( private http: HttpClient, private router: Router, private apiService: ApiService, private storageService: StorageService, private userService: UserService, private uuidService: UuidService, private toastService: ToastService, private titleService: Title, public DateUtilitiesService: DateUtilitiesService, public route: ActivatedRoute, @Inject(DOCUMENT) private document: any, private fb: FormBuilder ) { this.createFormGroup(); if (environment.autofill) { this.setDemoValues(); } else { this.calendar = [new Date()]; } } /** * add example values to the form */ setDemoValues(): void { this.addChoice('orange'); this.addChoice('raisin'); this.addChoice('abricot'); this.calendar = [ this.DateUtilitiesService.addDaysToDate(1, new Date()), this.DateUtilitiesService.addDaysToDate(2, new Date()), this.DateUtilitiesService.addDaysToDate(3, new Date()), ]; this.form.patchValue({ title: 'mon titre', description: 'répondez SVP <3 ! *-* ', custom_url: this.uuidService.getUUID(), creatorPseudo: 'Chuck Norris', creatorEmail: 'chucknorris@example.com', isAboutDate: true, whoModifiesAnswers: 'everybody', whoCanChangeAnswers: 'everybody', isProtectedByPassword: false, richTextMode: false, areResultsPublic: true, expiracyNumberOfDays: 60, }); this.automaticSlug(); } /** * set the poll slug from other data of the poll */ automaticSlug() { this.form.patchValue({ custom_url: this.makeSlug(this.form) }); } public createFormGroup() { let form = this.fb.group({ title: ['', [Validators.required, Validators.minLength(12)]], creatorPseudo: ['', [Validators.required]], created_at: [new Date(), [Validators.required]], creatorEmail: ['', [Validators.required]], custom_url: [this.uuidService.getUUID(), [Validators.required]], description: ['', [Validators.required]], password: ['', [Validators.required]], choices: new FormArray([]), whoModifiesAnswers: ['', [Validators.required]], whoCanChangeAnswers: ['', [Validators.required]], isAboutDate: [true, [Validators.required]], startDateInterval: ['', [Validators.required]], endDateInterval: ['', [Validators.required]], expiresDaysDelay: ['', [Validators.required]], maxCountOfAnswers: ['', [Validators.required]], isZeroKnoledge: [false, [Validators.required]], isProtectedByPassword: [false, [Validators.required]], isOwnerNotifiedByEmailOnNewVote: [true, [Validators.required]], isOwnerNotifiedByEmailOnNewComment: [true, [Validators.required]], areResultsPublic: [true, [Validators.required]], richTextMode: [false, [Validators.required]], isYesAnswerAvailable: [true, [Validators.required]], isMaybeAnswerAvailable: [true, [Validators.required]], isNoAnswerAvailable: [true, [Validators.required]], allowComments: [true, [Validators.required]], hasMaxCountOfAnswers: [300, [Validators.required]], useVoterUniqueLink: [false, [Validators.required]], voterEmailList: ['', [Validators.required]], allowNewDateTime: [60, [Validators.required, Validators.min(0)]], }); this.form = form; return form; } public updateSlug(): void { console.log('this.form.value', this.form.value); this.form.patchValue({ custom_url: this.makeSlug(this.form) }); } /** * auto fetch a poll when route is looking for one in the administration pattern * @param route * @param state */ public async resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise { console.log('resolve route,state', route, state); const segments: string[] = state.url.split('/'); const wantedcustom_url: string = segments.includes('poll') ? segments[segments.indexOf('poll') + 1] : ''; if ( !this._poll.getValue() || !this._poll.getValue().custom_url || this._poll.getValue().custom_url !== wantedcustom_url ) { if (this.pass_hash) { this.storageService.vote_stack.pass_hash = this.pass_hash; await this.loadPollBycustom_urlWithPasswordHash(wantedcustom_url, this.pass_hash); } else { await this.loadPollBycustom_url(wantedcustom_url); } } const loadedPoll = this._poll.getValue(); if (loadedPoll) { this.storageService.vote_stack.poll_custom_url = loadedPoll.custom_url; return loadedPoll; } else { this.router.navigate(['page-not-found']); return; } } /** * get all polls */ getAllAvailablePolls(): void { const baseHref = environment.api.version.apiV1.baseHref; console.log('getAllAvailablePolls baseHref', baseHref); const headers = ApiService.makeHeaders(); console.log('getAllAvailablePolls headers', headers); try { this.http.get(`${baseHref}/poll`, headers).subscribe((res: Observable) => { console.log('getAllAvailablePolls res', res); }); } catch (e) { console.log('getAllAvailablePolls e', e); } } public async loadPollBycustom_url(custom_url: string): Promise { if (custom_url) { const poll: Poll | undefined = await this.apiService.getPollByCustomUrl(custom_url); if (poll) { this.updateCurrentPoll(poll); this.titleService.setTitle(`☑️ ${poll.title} - ${environment.appTitle}`); } else { this.toastService.display(`sondage ${custom_url} non trouvé`); this.router.navigate(['page-not-found']); } } else { this.toastService.display(`sondage sans custom url : ${custom_url}`); } } public async loadPollBycustom_urlWithPasswordHash(custom_url: string, hash: string): Promise { if (custom_url) { const poll: Poll | undefined = await this.apiService.getPollByCustomUrlWithHash(custom_url, hash); if (poll) { this.updateCurrentPoll(poll); this.titleService.setTitle(`☑️ ${poll.title} - ${environment.appTitle}`); } else { this.toastService.display(`sondage ${custom_url} non trouvé`); this.router.navigate(['page-not-found']); } } else { this.toastService.display(`sondage sans custom url : ${custom_url}`); } } /** * update poll and parse its fields * @param poll */ public updateCurrentPoll(poll: Poll): void { console.log('this.storageService.vote_stack.id', this.storageService.vote_stack.id); if (!this.storageService.vote_stack.id || this.storageService.vote_stack.poll_custom_url !== poll.custom_url) { console.log('set base choices', poll.choices); // set the choices only the first time the poll loads, or if we changed the poll console.log( 'this.storageService.vote_stack.poll_custom_url', this.storageService.vote_stack.poll_custom_url ); // this.storageService.setChoicesForVoteStack(poll.choices); } this.toastService.display('sondage bien mis à jour', 'success'); this._poll.next(poll); } /** * add all the dates between the start and end dates in the interval section */ addIntervalOfDates(): void { const newIntervalArray = this.DateUtilitiesService.getDatesInRange( this.DateUtilitiesService.parseInputDateToDateObject(new Date(this.startDateInterval)), this.DateUtilitiesService.parseInputDateToDateObject(new Date(this.endDateInterval)), 1 ); const converted = []; newIntervalArray.forEach((element) => { converted.push({ literal: element.literal, date_object: element.date_object, timeList: [], }); }); this.dateList = [...new Set(converted)]; // add only dates that are not already present with a Set of unique items console.log('this.dateList', this.dateList); this.showDateInterval = false; this.form.patchValue({ choices: this.dateList }); this.toastService.display(`les dates ont été ajoutées aux réponses possibles.`); } /** * handle keyboard shortcuts * @param $event * @param choice_number */ keyOnChoice($event: KeyboardEvent, choice_number: number): void { $event.preventDefault(); const lastChoice = this.choices.length - 1 === choice_number; // reset field with Ctrl + D // add a field with Ctrl + N // go to previous choice with arrow up // go to next choice with arrow down if ($event.key == 'ArrowUp' && choice_number > 0) { this.focusOnChoice(choice_number - 1); } if ($event.key == 'ArrowDown') { // add a field if we are on the last choice if (lastChoice) { this.addChoice(); this.toastService.display('choix ajouté par raccourci "flèche bas"'); } else { this.focusOnChoice(choice_number + 1); } } if ($event.ctrlKey && $event.key == 'Backspace') { this.deleteChoiceField(choice_number); this.toastService.display('choix supprimé par raccourci "Ctrl + retour"'); this.focusOnChoice(Math.min(choice_number - 1, 0)); } if ($event.ctrlKey && $event.key == 'Enter') { // go to other fields const elem = this.document.querySelector('#creatorEmail'); if (elem) { elem.focus(); } } } /** * change time spans */ addTime() { this.timeList.push({ literal: '', }); } removeAllTimes() { this.timeList = []; } resetTimes() { this.timeList = []; } /** * add a time period to a specific date choice, * focus on the new input * @param config * @param id */ addTimeToDate(config: any, id: number) { this.timeList.push({ literal: '', }); const selector = '[ng-reflect-choice_label="dateTime_' + id + '_Choices_' + (this.timeList.length - 1) + '"]'; const elem = this.document.querySelector(selector); if (elem) { elem.focus(); } } public createPoll(): void { console.log('this.form', this.form); const newpoll = this.newPollFromForm(this.form); this.apiService.createPoll(newpoll).then((resp) => { console.log('poll created resp', resp); console.log('TODO fill admin_key'); this.admin_key = resp.data.admin_key; }); } /** * default interval of dates proposed is from today to 7 days more */ setDefaultDatesForInterval(): void { const dateCurrent = new Date(); const dateJson = dateCurrent.toISOString(); this.startDateInterval = dateJson.substring(0, 10); this.endDateInterval = this.DateUtilitiesService.addDaysToDate(this.intervalDaysDefault, dateCurrent) .toISOString() .substring(0, 10); this.form.patchValue({ startDateInterval: this.startDateInterval, endDateInterval: this.endDateInterval, }); this.countDays(); } askInitFormDefault(): void { this.initFormDefault(false); this.toastService.display('formulaire réinitialisé'); } countDays(): void { this.intervalDays = this.DateUtilitiesService.countDays( this.DateUtilitiesService.parseInputDateToDateObject(new Date(this.startDateInterval)), this.DateUtilitiesService.parseInputDateToDateObject(new Date(this.endDateInterval)) ); } focusOnChoice(index): void { const selector = '#choice_label_' + index; const elem = this.document.querySelector(selector); if (elem) { elem.focus(); } } deleteChoiceField(index: number): void { if (this.choices.length !== 1) { this.choices.removeAt(index); } } initFormDefault(showDemoValues = true): void { this.form = this.createFormGroup(); this.setDefaultDatesForInterval(); if (showDemoValues) { this.setDemoValues(); } } get choices(): FormArray { return this.form.get('choices') as FormArray; } reinitChoices(): void { this.choices.setValue([]); } addChoice(optionalLabel = ''): void { const newControlGroup = this.fb.group({ label: this.fb.control('', [Validators.required]), imageUrl: ['', [Validators.required]], }); if (optionalLabel) { newControlGroup.patchValue({ label: optionalLabel, imageUrl: 'mon url', }); } this.choices.push(newControlGroup); this.focusOnChoice(this.choices.length - 1); } /** * make a uniq slug for the current poll creation * @param form */ makeSlug(form: FormGroup): string { let str = ''; str = form.value.created_at.getFullYear() + '_' + (form.value.created_at.getMonth() + 1) + '_' + form.value.created_at.getDate() + '_' + form.value.creatorPseudo + '_' + form.value.title; str = str.replace(/^\s+|\s+$/g, ''); // trim str = str.toLowerCase(); // remove accents, swap ñ for n, etc const from = 'àáäâèéëêìíïîòóöôùúüûñç·/_,:;'; const to = 'aaaaeeeeiiiioooouuuunc------'; for (let i = 0, l = from.length; i < l; i++) { str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i)); } str = str .replace(/[^a-z0-9 -]/g, '') // remove invalid chars .replace(/\s+/g, '-') // collapse whitespace and replace by - .replace(/-+/g, '-'); // collapse dashes return str + '-' + this.uuidService.getUUID(); } public async saveCurrentPoll(): Promise { const pollUrl: Subscription = await this.apiService.createPoll(this._poll.getValue()); // TODO: Maybe handle the url to update currentPoll according to backend response if (pollUrl) { this.toastService.display('Le sondage a été enregistré.'); } else { this.toastService.display('Le sondage n’a été correctement enregistré, veuillez ré-essayer.'); } } public saveParticipation(choice: Choice, user: Owner, response: Answer): void { const currentPoll = this._poll.getValue(); currentPoll.choices.find((c) => c.name === choice.name)?.updateParticipation(user, response); this.updateCurrentPoll(currentPoll); this.apiService.createParticipation(currentPoll.custom_url, choice.name, user.pseudo, response); this.toastService.display('Votre participation au sondage a été enregistrée.'); } public async deleteAllAnswers(): Promise { await this.apiService.deletePollAnswers(this._poll.getValue().custom_url); this.toastService.display('Les participations des votants à ce sondage ont été supprimées.'); } public async addComment(comment: string): Promise { await this.apiService.createComment(this._poll.getValue().custom_url, comment); this.toastService.display('Votre commentaire a été enregistré.'); } public async deleteComments(): Promise { await this.apiService.deletePollComments(this._poll.getValue().custom_url); this.toastService.display('Les commentaires de ce sondage ont été supprimés.'); } public buildAnswersByChoiceLabelByPseudo(poll: Poll): Map> { const pseudos: Set = new Set(); poll.choices.forEach((choice: Choice) => { choice.participants.forEach((users: Set) => { users.forEach((user: Owner) => { pseudos.add(user.pseudo); }); }); }); const list = new Map>(); pseudos.forEach((pseudo: string) => { list.set( pseudo, new Map( poll.choices.map((choice: Choice) => { return [choice.name, undefined]; }) ) ); }); poll.choices.forEach((choice: Choice) => { choice.participants.forEach((users: Set, answer: Answer) => { users.forEach((user: Owner) => { list.get(user.pseudo).set(choice.name, answer); }); }); }); return list; } public getParticipationUrlFromForm(): string { return `${environment.frontDomain}#/poll/${this.form.value.custom_url}/consultation`; } public getAdministrationUrlFromForm(): string { // admin_key is filled after creation return `${environment.frontDomain}#/admin/${this.admin_key}/consultation`; } public getParticipationUrl(): string { // http://localhost:4200/#/poll/dessin-anime/consultation // TODO handle secure access // http://localhost:4200/#/poll/citron/consultation/secure/1c01ed9c94fc640a1be864f197ff808c let url = ''; if (this._poll && this._poll.getValue) { const polltemp = this._poll.getValue(); if (polltemp) { url = `${environment.frontDomain}#/poll/${polltemp.custom_url}/consultation`; } } else { url = `${environment.frontDomain}#/poll/${this.form.value.custom_url}/consultation`; } // TODO handle pass access return url; } public getAdministrationUrl(): string { // http://localhost:4200/#/admin/9S75b70ECXI5J5xDc058d3H40H9r2CHfO0Kj8T02EK2U8rY8fYTn-eS659j2Dhp794Oa6R1b9V70e3WGaE30iD9h45zwdm76C85SWB4LcUCrc7e0Ncc0 let url = ''; if (this._poll && this._poll.getValue) { const polltemp = this._poll.getValue(); if (polltemp) { url = `${environment.frontDomain}#/admin/${polltemp.admin_key}`; } } else { url = `${environment.frontDomain}#/admin/${this.form.value.admin_key}`; } return url; } /** * enrich vote stack with missing default votes * @param vote_stack */ enrichVoteStackWithCurrentPollChoicesDefaultVotes(vote_stack: Stack) { if (this._poll && this._poll.getValue) { const polltemp = this._poll.getValue(); polltemp.choices.map((choice) => { // for each vote, if it has the choice_id, do nothing, else, add a default vote if (!this.findExistingVoteFromChoiceId(choice.id, vote_stack.votes)) { vote_stack.votes.push(new Vote(choice.id)); } }); } } /** * find an existing vote in vote_stack from its choice_id * @param choice_id * @param votes */ findExistingVoteFromChoiceId(choice_id: number, votes: Vote[]) { return votes.find((vote: Vote) => { if (vote.choice_id === choice_id) { return vote; } }); } convertCalendarDatesToChoices(array_dates) { return array_dates; } /** * @description convert to API version 1 data transition object * @param form */ newPollFromForm(form: any): Poll { const newOwner = this.storageService.vote_stack.owner; const newpoll = new Poll(newOwner, form.value.custom_url, form.value.title); const pollKeys = Object.keys(newpoll); const formFields = Object.keys(form.value); newpoll.allowed_answers = []; for (const pk of pollKeys) { if (formFields.indexOf(pk) !== -1) { const field = form.value[pk]; newpoll[pk] = field; } else { console.log('manque pollKey', pk); } } if (form.value.isYesAnswerAvailable) { newpoll.allowed_answers.push('yes'); } if (form.value.isMaybeAnswerAvailable) { newpoll.allowed_answers.push('maybe'); } if (form.value.isNoAnswerAvailable) { newpoll.allowed_answers.push('no'); } newpoll.description = form.value.description; newpoll.has_several_hours = form.value.hasSeveralHours; newpoll.hasSeveralHours = form.value.hasSeveralHours; newpoll.max_count_of_answers = form.value.allowComments; newpoll.maxCountOfAnswers = form.value.maxCountOfAnswers; newpoll.password = form.value.password; newpoll.kind = form.value.kind; newpoll.allow_comments = form.value.allowComments; // merge choices from storage if (form.value.kind === 'date') { // convert calendar picker dates } newpoll.choices = Object.assign([], this.storageService.choices); newpoll.dateChoices = Object.assign([], this.storageService.dateChoices); newpoll.timeSlices = Object.assign([], this.storageService.timeSlices); console.log('newpoll', newpoll); return newpoll; } }