github.com/benoitkugler/goacve@v0.0.0-20201217100549-151ce6e55dc8/server/inscriptions/controller.go (about)

     1  // Expose les fonctionnalités du formulaire d'inscription
     2  // aux séjours ACVE.
     3  package inscriptions
     4  
     5  import (
     6  	"errors"
     7  	"fmt"
     8  	"net/url"
     9  	"path"
    10  	"sort"
    11  	"strings"
    12  	"time"
    13  
    14  	rd "github.com/benoitkugler/goACVE/server/core/rawdata"
    15  	"github.com/benoitkugler/goACVE/server/core/rawdata/composites"
    16  	"github.com/benoitkugler/goACVE/server/core/rawdata/matching"
    17  	"github.com/benoitkugler/goACVE/server/core/utils/mails"
    18  	"github.com/benoitkugler/goACVE/server/shared"
    19  )
    20  
    21  // adresse de confirmation du mail
    22  var PathValidMail = path.Join(UrlInscriptions, EndPointValidMail)
    23  
    24  type Controller struct {
    25  	shared.Controller
    26  }
    27  
    28  // Preinscription code le choix d'un responsable et des participants associés.
    29  type Preinscription struct {
    30  	IdResponsable int64
    31  	IdsEnfants    rd.Ids
    32  }
    33  
    34  type CampSimple struct {
    35  	Id        int64     `json:"id"`
    36  	IdGroupe  int64     `json:"id_groupe"`
    37  	Label     rd.String `json:"label"`
    38  	Prix      rd.Euros  `json:"prix"`
    39  	DateDebut rd.Date   `json:"date_debut"`
    40  	DateFin   rd.Date   `json:"date_fin"`
    41  	AgeMin    rd.Int    `json:"age_min"`
    42  	AgeMax    rd.Int    `json:"age_max"`
    43  }
    44  
    45  func (c *CampSimple) From(camp rd.Camp) {
    46  	c.Id = camp.Id
    47  	c.Label = camp.Label()
    48  	c.Prix = camp.Prix
    49  	c.AgeMax = camp.AgeMax
    50  	c.AgeMin = camp.AgeMin
    51  	c.DateDebut = camp.DateDebut
    52  	c.DateFin = camp.DateFin
    53  }
    54  
    55  type dataCamps struct {
    56  	camps   rd.Camps
    57  	groupes rd.Groupes
    58  }
    59  
    60  // triCamps différencie les camps simple ou non
    61  func (d dataCamps) triCamps() ([]shared.Camp, map[int64]CampSimple) {
    62  	out1 := make([]shared.Camp, 0, len(d.camps))
    63  	out2 := make(map[int64]CampSimple)
    64  	for _, camp := range d.camps {
    65  		if camp.InscriptionSimple {
    66  			var pCamp CampSimple
    67  			pCamp.From(camp)
    68  			out2[camp.Id] = pCamp
    69  		} else {
    70  			var pCamp shared.Camp
    71  			pCamp.From(camp)
    72  			out1 = append(out1, pCamp)
    73  		}
    74  	}
    75  	sort.Slice(out1, func(i, j int) bool {
    76  		return out1[i].Label < out1[j].Label
    77  	})
    78  	return out1, out2
    79  }
    80  
    81  // loadCamps renvoie les camps ouverts aux inscriptions et dont la date de fin n'est pas passée
    82  func (ct Controller) loadCamps() (dataCamps, error) {
    83  	rows, err := ct.DB.Query("SELECT * FROM camps WHERE ouvert = true")
    84  	if err != nil {
    85  		return dataCamps{}, err
    86  	}
    87  	camps, err := rd.ScanCamps(rows)
    88  	if err != nil {
    89  		return dataCamps{}, err
    90  	}
    91  	for id, camp := range camps {
    92  		if camp.DateFin.Time().Before(time.Now()) {
    93  			delete(camps, id)
    94  		}
    95  	}
    96  	rows, err = ct.DB.Query("SELECT * FROM groupes WHERE id_camp = ANY($1)", camps.Ids().AsSQL())
    97  	if err != nil {
    98  		return dataCamps{}, err
    99  	}
   100  	groupes, err := rd.ScanGroupes(rows)
   101  	if err != nil {
   102  		return dataCamps{}, err
   103  	}
   104  	return dataCamps{camps: camps, groupes: groupes}, nil
   105  }
   106  
   107  // vérifie si le mail est le même
   108  func (ct Controller) hasMailChanged(mail rd.String, idPersonne rd.IdentificationId) (bool, error) {
   109  	personne, err := rd.SelectPersonne(ct.DB, idPersonne.Id)
   110  	if err != nil {
   111  		return false, shared.FormatErr("Impossible de consulter le profil du responsable.", err)
   112  	}
   113  	return personne.Mail.ToLower() != mail.ToLower(), nil
   114  }
   115  
   116  type candidatsPreinscription struct {
   117  	responsables            []rd.Personne
   118  	idsParticipantPersonnes rd.Ids // participants cumulés
   119  }
   120  
   121  // chercheMail renvoie les personnes ayant le mail fourni
   122  func (ct Controller) chercheMail(mail string) (candidatsPreinscription, error) {
   123  	mail = strings.TrimSpace(mail)
   124  	if len(mail) <= 1 {
   125  		return candidatsPreinscription{}, nil
   126  	}
   127  	rows, err := ct.DB.Query("SELECT * FROM personnes WHERE mail = $1", mail)
   128  	if err != nil {
   129  		return candidatsPreinscription{}, err
   130  	}
   131  	pers, err := rd.ScanPersonnes(rows)
   132  	if err != nil {
   133  		return candidatsPreinscription{}, err
   134  	}
   135  	var (
   136  		ids rd.Ids
   137  		out candidatsPreinscription
   138  	)
   139  	for id, pers := range pers {
   140  		ids = append(ids, id)
   141  		out.responsables = append(out.responsables, pers)
   142  	}
   143  	sort.Slice(out.responsables, func(i int, j int) bool {
   144  		return out.responsables[i].NomPrenom() < out.responsables[j].NomPrenom()
   145  	})
   146  	rows, err = ct.DB.Query(`SELECT participants.id_personne FROM participants
   147  		JOIN factures ON participants.id_facture = factures.id
   148  		WHERE factures.id_personne = ANY($1)`, ids.AsSQL())
   149  	if err != nil {
   150  		return candidatsPreinscription{}, err
   151  	}
   152  	idsEnfants, err := rd.ScanIds(rows)
   153  	if err != nil {
   154  		return candidatsPreinscription{}, err
   155  	}
   156  	out.idsParticipantPersonnes = idsEnfants.AsSet().Keys() // unicité
   157  	return out, nil
   158  }
   159  
   160  func (ct Controller) buildLiensPreinscription(cd candidatsPreinscription, origin string) ([]mails.TargetRespo, error) {
   161  	baseUrl, err := url.Parse(origin)
   162  	if err != nil {
   163  		return nil, err
   164  	}
   165  	var out []mails.TargetRespo
   166  	for _, resp := range cd.responsables {
   167  		t := Preinscription{IdResponsable: resp.Id, IdsEnfants: cd.idsParticipantPersonnes}
   168  		crypted, err := shared.Encode(ct.Signing, t)
   169  		if err != nil {
   170  			return nil, err
   171  		}
   172  		lien := shared.BuildUrl(baseUrl.Host, baseUrl.Path, map[string]string{"preinscription": crypted})
   173  		out = append(out, mails.TargetRespo{Lien: lien, NomPrenom: resp.NomPrenom().String()})
   174  	}
   175  	return out, nil
   176  }
   177  
   178  // decodePreinscription parse le lien du mail et forme une inscription pré-remplie.
   179  func (ct Controller) decodePreinscription(crypted string) (insc rd.Inscription, err error) {
   180  	var pre Preinscription
   181  	if err := shared.Decode(ct.Signing, crypted, &pre); err != nil {
   182  		return insc, shared.FormatErr("Le lien d'inscription rapide que vous utilisez semble invalide.", err)
   183  	}
   184  	respo, err := rd.SelectPersonne(ct.DB, pre.IdResponsable)
   185  	if err != nil {
   186  		return insc, shared.FormatErr("Le responsable n'a pu être retrouvé.", err)
   187  	}
   188  	rows, err := ct.DB.Query("SELECT * FROM personnes WHERE id = ANY($1)", pre.IdsEnfants.AsSQL())
   189  	if err != nil {
   190  		return insc, shared.FormatErr("Les participants n'ont pu être retrouvés.", err)
   191  	}
   192  	parts, err := rd.ScanPersonnes(rows)
   193  	if err != nil {
   194  		return insc, shared.FormatErr("Les participants n'ont pu être retrouvés.", err)
   195  	}
   196  
   197  	insc.Responsable = respo.ToInscription()
   198  	insc.Responsable.Lienid.Crypted, err = shared.EncodeID(ct.Signing, shared.OrPreIdentification, respo.Id)
   199  	if err != nil {
   200  		return insc, shared.FormatErr("Le cryptage des identifiants a échoué.", err)
   201  	}
   202  
   203  	for _, part := range parts {
   204  		partInsc := part.ToParticipantInscription()
   205  		partInsc.Lienid.Crypted, err = shared.EncodeID(ct.Signing, shared.OrPreIdentification, part.Id)
   206  		if err != nil {
   207  			return insc, shared.FormatErr("Le cryptage des identifiants a échoué.", err)
   208  		}
   209  		insc.Participants = append(insc.Participants, partInsc)
   210  	}
   211  	return insc, nil
   212  }
   213  
   214  func (ct Controller) decodeLienId(lienid *rd.IdentificationId) error {
   215  	if lienid.Crypted != "" {
   216  		lienId, err := shared.DecodeID(ct.Signing, lienid.Crypted, shared.OrPreIdentification)
   217  		if err != nil {
   218  			return shared.FormatErr("Le cryptage de la pré-identification semble incorrect.", err)
   219  		}
   220  		*lienid = rd.IdentificationId{Id: lienId, Valid: true}
   221  	}
   222  	return nil
   223  }
   224  
   225  func valideInscription(insc rd.Inscription) error {
   226  	if insc.Responsable.Nom.TrimSpace() == "" {
   227  		return errors.New("Merci de préciser votre nom.")
   228  	}
   229  	if insc.Responsable.Prenom.TrimSpace() == "" {
   230  		return errors.New("Merci de préciser votre prénom.")
   231  	}
   232  	if insc.Responsable.DateNaissance.Time().IsZero() {
   233  		return errors.New("Merci de fournir votre date de naissance.")
   234  	}
   235  	if len(insc.Participants) == 0 {
   236  		return errors.New("Aucun participant n'a été choisi !")
   237  	}
   238  	age := rd.CalculeAge(insc.Responsable.DateNaissance, time.Time{}).Age()
   239  	if age < 18 {
   240  		return errors.New("Le responsable légal doit être majeur !")
   241  	}
   242  	return nil
   243  }
   244  
   245  // transforme l'inscription en dossier, l'enregistre et garde un log
   246  // renvoie le dossier créé et son responsable
   247  // `camps` et `groupes` doivent contenir les camps et groupes concernés par l'inscription
   248  func (ct Controller) enregistreInscription(insc rd.Inscription, confirmed bool, data dataCamps) (rd.Facture, rd.Personne, error) {
   249  	insc.DateHeure = rd.Time(time.Now())
   250  
   251  	type identifiedPersonne struct {
   252  		personne rd.Personne
   253  		lienid   rd.IdentificationId
   254  	}
   255  
   256  	// on regroupe les personnes à identifier
   257  	var allPers []identifiedPersonne
   258  	ids := rd.NewSet()
   259  
   260  	allPers = append(allPers, identifiedPersonne{personne: insc.Responsable.ToPersonne(), lienid: insc.Responsable.Lienid})
   261  	ids.Add(insc.Responsable.Lienid.Id)
   262  	for _, part := range insc.Participants {
   263  		allPers = append(allPers, identifiedPersonne{personne: part.ToPersonne(), lienid: part.Lienid})
   264  		ids.Add(part.Lienid.Id)
   265  	}
   266  
   267  	tx, err := ct.DB.Begin()
   268  	if err != nil {
   269  		return rd.Facture{}, rd.Personne{}, err
   270  	}
   271  
   272  	// on charge les personnes pour la comparaison
   273  	rows, err := tx.Query("SELECT * FROM personnes WHERE id = ANY($1)", rd.Ids(ids.Keys()).AsSQL())
   274  	if err != nil {
   275  		return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   276  	}
   277  	personnes, err := rd.ScanPersonnes(rows)
   278  	if err != nil {
   279  		return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   280  	}
   281  
   282  	for i, personneAndId := range allPers {
   283  		var personne rd.Personne
   284  		if existante, isInBase := personnes[personneAndId.lienid.Id]; personneAndId.lienid.Valid && isInBase {
   285  			// si l'inscription est préidentifiée, on fusionne automatiquement
   286  			// l'inscription avec le profil
   287  			existante.BasePersonne, _ = matching.Merge(personneAndId.personne.BasePersonne, existante.BasePersonne)
   288  			personne, err = existante.Update(tx)
   289  		} else {
   290  			// sinon, on crée une nouvelle personne temporaire
   291  			personneAndId.personne.IsTemporaire = true
   292  			personne, err = personneAndId.personne.Insert(tx)
   293  		}
   294  		if err != nil {
   295  			return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   296  		}
   297  		allPers[i].personne = personne // on a besoin de l'id plus tard
   298  	}
   299  	responsablePersonne := allPers[0].personne // le responsable est en premier
   300  
   301  	key, err := shared.GetNewKey(tx)
   302  	if err != nil {
   303  		return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   304  	}
   305  	fac := rd.Facture{
   306  		IdPersonne:        responsablePersonne.Id,
   307  		Key:               rd.String(key),
   308  		CopiesMails:       insc.CopiesMails,
   309  		IsConfirmed:       confirmed,
   310  		IsValidated:       false,
   311  		PartageAdressesOK: insc.PartageAdressesOK,
   312  	}
   313  	fac, err = fac.Insert(tx)
   314  	if err != nil {
   315  		return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   316  	}
   317  
   318  	// on garde une trace du moment d'inscription
   319  	messageTime := rd.Message{
   320  		IdFacture: fac.Id,
   321  		Kind:      rd.MInscription,
   322  		Created:   rd.Time(time.Now()),
   323  	}
   324  	_, err = messageTime.Insert(tx)
   325  	if err != nil {
   326  		return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   327  	}
   328  
   329  	// on insert le message du formulaire
   330  	if me := insc.Info.TrimSpace(); me != "" {
   331  		message := rd.Message{
   332  			IdFacture: fac.Id,
   333  			Kind:      rd.MResponsable,
   334  			Created:   rd.Time(time.Now().Add(time.Second)), // on s'assure que le message vient après le moment d'inscription
   335  		}
   336  		message, err = message.Insert(tx)
   337  		if err != nil {
   338  			return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   339  		}
   340  		mMessage := rd.MessageMessage{IdMessage: message.Id, Contenu: me, GuardKind: rd.MResponsable}
   341  		err = rd.InsertManyMessageMessages(tx, mMessage)
   342  		if err != nil {
   343  			return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   344  		}
   345  	}
   346  
   347  	// le choix attente/inscrit nécessite les participants
   348  	participants, err := rd.SelectParticipantsByIdCamps(tx, data.camps.Ids()...)
   349  	if err != nil {
   350  		return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   351  	}
   352  
   353  	// on ajoute maintenant les participants
   354  	for i, inscPart := range insc.Participants {
   355  		personne := allPers[i+1].personne // personne créée ou mise à jour
   356  		camp := data.camps[inscPart.IdCamp]
   357  
   358  		campP := composites.NewCampParticipants(camp, participants)
   359  		if campP.IsParticipantAlreadyHere(personne.Id) {
   360  			_ = shared.Rollback(tx, nil)
   361  			return rd.Facture{}, rd.Personne{}, fmt.Errorf("%s est déjà inscrit sur le séjour %s",
   362  				personne.NomPrenom(), campP.Label())
   363  		}
   364  
   365  		campG := composites.NewCampGroupes(camp, data.groupes)
   366  
   367  		statutAttente := campP.HintsAttente(personne.BasePersonne, false, personnes).Hint()
   368  		participant := rd.Participant{
   369  			IdCamp:       inscPart.IdCamp,
   370  			IdPersonne:   personne.Id,
   371  			IdFacture:    rd.NewOptionnalId(fac.Id),
   372  			ListeAttente: rd.ListeAttente{Statut: statutAttente},
   373  			Options:      inscPart.Options,
   374  			OptionPrix:   inscPart.OptionsPrix,
   375  			DateHeure:    rd.Time(time.Now()),
   376  		}
   377  		participant, err = participant.Insert(tx)
   378  		if err != nil {
   379  			return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   380  		}
   381  
   382  		groupe, hasFound := campG.TrouveGroupe(inscPart.DateNaissance)
   383  		if hasFound {
   384  			// on ajoute automatiquement le nouveau participant au groupe
   385  			lien := rd.GroupeParticipant{IdGroupe: groupe.Id, IdCamp: groupe.IdCamp, IdParticipant: participant.Id}
   386  			err = rd.InsertManyGroupeParticipants(tx, lien)
   387  			if err != nil {
   388  				return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   389  			}
   390  		}
   391  	}
   392  
   393  	// on garde un log de l'inscription brute
   394  	if _, err = insc.Insert(tx); err != nil {
   395  		return rd.Facture{}, rd.Personne{}, shared.Rollback(tx, err)
   396  	}
   397  	err = tx.Commit()
   398  	return fac, responsablePersonne, err
   399  }
   400  
   401  // envoie un mail de demande de confirmation
   402  func (ct Controller) verifieMail(host string, facture rd.Facture, responsable rd.Personne) error {
   403  	cryptedId, err := shared.EncodeID(ct.Signing, shared.OrValidationMail, facture.Id)
   404  	if err != nil {
   405  		return shared.FormatErr("Le cryptage des identifiants a échoué.", err)
   406  	}
   407  
   408  	urlValide := shared.BuildUrl(host, PathValidMail, map[string]string{
   409  		"crypted-id": cryptedId,
   410  	})
   411  
   412  	html, err := mails.NewValideMail(urlValide, mails.Contact{Prenom: responsable.FPrenom(), Sexe: responsable.Sexe})
   413  	if err != nil {
   414  		return shared.FormatErr("La création du mail a échoué.", err)
   415  	}
   416  	if err = mails.NewMailer(ct.SMTP).SendMail(string(responsable.Mail), "[ACVE] Vérification de l'adresse mail", html, nil, nil); err != nil {
   417  		return shared.FormatErr("L'envoi du mail de vérification a échoué.", err)
   418  	}
   419  	return nil
   420  }
   421  
   422  func (ct Controller) confirmeInscription(cryptedId string) (rd.Facture, error) {
   423  	idFacture, err := shared.DecodeID(ct.Signing, cryptedId, shared.OrValidationMail)
   424  	if err != nil {
   425  		return rd.Facture{}, err
   426  	}
   427  	row := ct.DB.QueryRow("UPDATE factures SET is_confirmed = true WHERE id = $1 RETURNING *", idFacture)
   428  	facture, err := rd.ScanFacture(row)
   429  	if err != nil {
   430  		return rd.Facture{}, shared.FormatErr("La validation de l'adresse a échoué.", err)
   431  	}
   432  	return facture, nil
   433  }
   434  
   435  func (ct Controller) enregistreInscriptionSimple(insc InscriptionSimple, camp CampSimple) error {
   436  	tx, err := ct.DB.Begin()
   437  	if err != nil {
   438  		return shared.FormatErr("Base de données injoinable.", err)
   439  	}
   440  
   441  	// on enregistre directement le participant (lié à une personne temporaire)
   442  	personne := rd.Personne{
   443  		BasePersonne: rd.BasePersonne{
   444  			Nom:           insc.Nom,
   445  			Prenom:        insc.Prenom,
   446  			DateNaissance: insc.DateNaissance,
   447  			Sexe:          insc.Sexe,
   448  			Mail:          insc.Mail,
   449  			Tels:          rd.Tels{insc.Tel},
   450  		},
   451  		IsTemporaire: true,
   452  	}
   453  	personne, err = personne.Insert(tx)
   454  	if err != nil {
   455  		return shared.Rollback(tx, err)
   456  	}
   457  	participant := rd.Participantsimple{
   458  		IdCamp:     camp.Id,
   459  		IdPersonne: personne.Id,
   460  		Info:       insc.Info,
   461  		DateHeure:  rd.Time(time.Now()),
   462  	}
   463  	participant, err = participant.Insert(tx)
   464  	if err != nil {
   465  		return shared.Rollback(tx, err)
   466  	}
   467  
   468  	if err = tx.Commit(); err != nil {
   469  		return shared.FormatErr("L'enregistrement de l'inscription a échoué.", err)
   470  	}
   471  	return nil
   472  }