serverless, aws, mongodb

Créer une page d'inscription pour une fête avec Serverless et MongoDB

2026-04-07
This post cover image
aws cloud serverless mongodb lambda api-gateway

Ce fichier a ete traduit automatiquement par IA, des erreurs peuvent survenir

Chaque année, ma femme et moi organisons une grande fête costumée d'été. Environ 50 de nos amis viennent déguisés, nous avons des jeux, un quiz, de la nourriture et beaucoup de plaisir. Mais, chaque année, c'est la même chose : suivre les invitations, suivre les RSVP, envoyer les mêmes informations encore et encore. J'ai essayé de résoudre cela avec un événement Facebook, mais certaines personnes refusent d'utiliser Facebook, d'autres ne RSVP que pour elles-mêmes, me laissant me demander "Est-ce que votre partenaire vient aussi ?"

Alors cette année, le constructeur en moi s'est réveillé et j'ai construit une application web pour cela.

Les invités reçoivent un code personnel, se connectent, RSVP, vérifient le code vestimentaire. J'obtiens un tableau de bord admin où je peux voir qui vient, qui m'ignore, suivre l'activité et voir les restrictions alimentaires que je dois gérer lors de la course aux courses.

Cela a commencé comme un projet de week-end amusant. Mais plus je construisais, plus je réalisais que cela pourrait fonctionner pour pratiquement n'importe quel événement. L'architecture reste la même qu'il s'agisse de 50 ou de 500 invités.

Le code est disponible sur Serverless Handbook.

Aperçu de l'architecture

Voici donc l'architecture de base : serverless et une base de données, dans ce cas MongoDB.

Aperçu de l'architecture

Le frontend est une application React 19 construite avec Vite, hébergée sur S3 derrière CloudFront. Tailwind pour le style. Rien de bien nouveau là-dedans non plus.

Le backend est là où cela devient plus intéressant. Des fonctions Lambda derrière API Gateway, avec des autorisateurs Lambda séparés contrôlant l'accès via le modèle PDP/PEP. J'ai décrit ce modèle en détail dans un article précédent si vous voulez avoir une image complète.

Pour la base de données, j'ai choisi MongoDB Atlas. Plus sur cela plus tard, mais en bref, j'ai essayé DynamoDB mais j'en ai eu marre de tant d'index GSIs différents.

L'authentification est des JWT auto-signés, similaires à mon AI Bartender. Le PDP (service d'auth central) signe les jetons avec une clé privée. Les autorisateurs Lambda personnalisés dans API Gateway (PEP) récupèrent simplement la clé publique et peuvent valider le JWT. Simple, et difficile à se tromper.

Tout est défini dans des modèles SAM et cette fois je l'ai déployé sur eu-north-1 car je suis en Suède et mes invités sont locaux.

Authentification vers Atlas

L'authentification vers Atlas pourrait bien sûr être faite avec un nom d'utilisateur et un mot de passe traditionnels. Mais dans mon article précédent, j'ai introduit l fédération d'identité sortante avec MongoDB. Et bien sûr, c'est ce que j'utilise.

L'authentification utilisateur simplifiée

Je ne voulais pas que mes invités aient à s'inscrire et à définir un nom d'utilisateur et un mot de passe. Mes invités RSVP pour une fête dans le jardin, pas pour s'inscrire à un produit SaaS. Donc je voulais quelque chose de simple, finalement j'ai opté pour utiliser un jeton personnel associé à leur nom de famille.

Chaque invité reçoit un code que je génère dans le tableau de bord admin. Ils entrent leur code et leur nom de famille. C'est tout le processus de connexion.

Voici donc le flux de connexion des invités.

Le flux de connexion

  • Le frontend (utilisateur) envoie le code et le nom de famille à POST /auth/login
  • API Gateway redirige vers un Lambda Proxy de connexion
  • Ce Lambda appelle directement le Lambda PDP (Lambda-à-Lambda)
  • Le PDP recherche le jeton dans MongoDB, vérifie le nom de famille
  • Si cela correspond, il signe un JWT avec la clé privée RSA
  • Le frontend stocke le JWT, l'envoie comme jeton Bearer à chaque requête suivante

Le JWT contient l'ID de l'invité, son nom et s'il est admin. Signé avec RS256, donc le PDP détient la clé privée et tout le reste a juste besoin de la clé publique. Pas de secrets partagés.

Maintenant, l'appel Lambda-à-Lambda lorsque Login appelle le PDP, c'est loin d'être une meilleure pratique, parfois c'est OK, et dans cette petite solution simple j'ai opté pour cela. Dans une configuration de production, mon PDP serait derrière un API Gateway, pas de Lambda-à-Lambda dans ce cas.

J'ai dit que cette solution pourrait être utilisée pour n'importe quel événement de n'importe quelle taille, et nous remplacerions notre petit flux de connexion par une inscription appropriée utilisant Cognito User Pools, Okta, Auth0 ou similaire. Ajouter Stripe Checkout devant cela et nous obtenons également des billets payés.

Pourquoi MongoDB et pas DynamoDB

Dans ma première version, j'ai utilisé DynamoDB, cela fonctionnait bien pour 50 invités. Puis j'ai commencé à ajouter des fonctionnalités, la possibilité de connecter les invités, le suivi de l'activité et plus encore. J'ai ajouté quelques index supplémentaires mais j'ai fini par avoir plusieurs scans.

Par exemple, dans le tableau de bord admin, je liste tous les invités. Avec DynamoDB, cela résultait en un Scan. Bien sûr, je pourrais probablement ajouter un index où la clé de partition serait un faux nom de fête et l'ID d'invité comme clé de tri. Mais ce n'était pas ce que je voulais.

Ensuite est venu le filtrage comme Montre-moi tous ceux qui n'ont pas RSVP. C'était encore un scan et un filtrage dans votre code, ou je pouvais construire un GSI à nouveau, pour chaque modèle de requête que je pouvais maintenant envisager.

Alors j'ai commencé à réfléchir, mais qu'en est-il d'un événement avec 500, 1000 ou 10 000 invités, continuer à faire des scans ne serait pas très génial.

Et puis sont venues les relations. Ma fête a des couples et des familles. John et Jane forment un couple, ils partagent un RSVP. Je l'ai modélisé dans DynamoDB avec un champ connectedTo, un lien bidirectionnel entre les enregistrements. Bien pour deux personnes. Mais une famille de quatre ? Un groupe d'amis ? Connecter deux invités signifie mettre à jour les deux enregistrements. Déconnecter signifie trouver l'autre personne et mettre à jour les deux à nouveau. Cela devenait rapidement désordonné.

L'agrégation..... Combien d'invités au total viennent ? scanner tout dans la fonction Lambda, parcourir, additionner.

Cela ressemblait plus à ce dont j'avais besoin d'une base de données relationnelle où je pourrais faire des requêtes, trier, filtrer et agréger. En même temps, j'avais besoin de la possibilité pour un nombre dynamique de colonnes (cles) par invité, ce qui est l'un des grands avantages de DynamoDB.

C'est là que j'ai décidé de passer à MongoDB Atlas et de tirer le meilleur des deux mondes.

Le modèle de données MongoDB

J'ai décidé d'opter pour une collection : un document par invité. Pas de jointures et pas de références à suivre.

{
  "_id": ObjectId("..."),
  "firstName": "John",
  "lastName": "Doe",
  "token": "vTFSxGE3",
  "status": "pending",
  "numGuests": 0,
  "dietary": "",
  "expectedGuests": 1,
  "isAdmin": false,
  "groupId": "doe-family",
  "rsvpDate": null,
  "lastLogin": null,
  "lastAccess": null,
  "createdAt": ISODate("2026-04-01T18:00:00Z")
}

Le champ intéressant est groupId. C'est tout le modèle de relation, c'est ainsi que les couples et les groupes sont maintenant connectés.

Des groupes au lieu de paires

Dans DynamoDB, j'avais connectedTo: "jane-uuid" sur l'enregistrement de John et connectedTo: "john-uuid" sur celui de Jane. Liens bidirectionnels. Cela fonctionne pour deux personnes mais pas pour des groupes.

La version MongoDB est plus simple. Tout le monde dans une famille ou un groupe partage le même groupId. Pas besoin de mettre à jour deux enregistrements juste pour connecter des gens.

Pour un couple, ce serait :

{ firstName: "John", groupId: "doe-family", expectedGuests: 1 }
{ firstName: "Jane", groupId: "doe-family", expectedGuests: 1 }

Et pour une famille de 4, c'est la même chose :

{ firstName: "Bob",    groupId: "smith-family", expectedGuests: 1 }
{ firstName: "Alice",  groupId: "smith-family", expectedGuests: 1 }
{ firstName: "Tom",    groupId: "smith-family", expectedGuests: 1 }
{ firstName: "Emma",   groupId: "smith-family", expectedGuests: 1 }

Mais qu'est-ce que cet expectedGuests et qu'est-ce que cela apporte et comment cela fonctionne-t-il ? Le expectedGuests de chaque membre inclut lui-même plus toute personne qu'il amène et qui n'a pas son propre login. Par exemple, dans une famille avec de jeunes enfants, les enfants n'auraient pas leur propre login, donc le expectedGuests des parents serait > 1.

Si je veux maintenant ajouter quelqu'un aux groupes, c'est une mise à jour du document de cette personne.

db.guests.update_one(
    {"_id": new_member_id},
    {"$set": {"groupId": existing_group_id}}
)

Retirer quelqu'un ? Pareil, une mise à jour.

db.guests.update_one(
    {"_id": member_id},
    {"$set": {"groupId": None}}
)

Comparez cela à la version DynamoDB où connecter deux invités signifiait mettre à jour les deux enregistrements, vérifier qu'aucun n'était déjà connecté à quelqu'un d'autre, et gérer les cas d'erreur pour les deux écritures.

Index

Passer à MongoDB ne signifie pas que je peux sauter les index. J'en ai toujours besoin, comme je le ferais avec une base de données relationnelle telle que PostgreSQL ou MySQL. Mais un index MongoDB (ou un index PostgreSQL) et un GSI DynamoDB ne sont pas la même chose. Ils partagent un nom et c'est à peu près tout.

Un index dans MongoDB ou PostgreSQL est une structure de données légère. Un pointeur, il dit essentiellement "si vous cherchez des documents où status est pending, les voici". Les données originales restent où elles sont. L'index rend simplement les recherches rapides. Je peux en créer un sur n'importe quel champ à tout moment, et cela ne me coûte qu'un peu de stockage et un peu de surcharge d'écriture.

Un GSI dans DynamoDB, c'est une copie complète des données. Lorsque je crée un GSI, DynamoDB duplique chaque élément, ou les attributs que je décide de projeter, dans une structure séparée avec sa propre clé de partition et sa propre clé de tri. Je paie pour le stockage des deux copies. Je paie pour la capacité d'écriture sur les deux.

Cette différence compte beaucoup. Dans MongoDB, ajouter un index sur status ne coûte presque rien et je peux le interroger comme je le veux, égalité, inégalité, plages, regex, tout ce que je veux. Dans DynamoDB, ajouter un GSI signifie décider à l'avance quelle attribut est la clé de partition. Chaque nouveau modèle d'accès potentiellement signifie un nouveau GSI avec son propre coût et ses compromis de cohérence éventuelle.

Donc quand je dis que mes index MongoDB sont plus flexibles, c'est ce que je veux dire.

J'ai donc quatre index. Chacun existe en raison d'une requête réelle que l'application exécute.

# Recherche de connexion. Chaque connexion frappe cela. Doit être unique.
guests.create_index("token", unique=True)

# Recherche de groupe. Chaque RSVP récupère tous les membres du groupe.
guests.create_index("groupId", sparse=True)

# Filtre de statut. Le tableau de bord admin filtre par en attente/en train de venir/refusé.
guests.create_index("status")

# Composé : statut + diététique. Rapport d'allergie pour les invités qui viennent.
guests.create_index([("status", 1), ("dietary", 1)])

Le sparse: True sur groupId signifie que les invités solos avec groupId: null ne sont pas dans l'index. Cela économise un peu d'espace, mais n'affecte pas les performances des requêtes puisque je ne recherche par groupId que lorsque je sais qu'il existe, je n'ai pas besoin de trouver un invité sans connexion, pas pour l'instant anyway.

Paramètres

Je dois définir une date limite pour les RSVP, donc si un invité essaie de RSVP ou de changer son RSVP après cette date, il doit réellement m'appeler ou m'envoyer un SMS directement. Après une certaine date, je ne veux pas continuer à vérifier les changements de RSVP, car c'est trop proche de la date de la fête.

Dans DynamoDB, j'ai stocké la date limite des RSVP dans la même table que les invités, en utilisant la conception de table unique.

Dans MongoDB, j'ai décidé de donner aux paramètres leur propre collection :

db.settings.find_one({"_id": "app-settings"})
{
  "_id": "app-settings",
  "rsvpDeadline": ISODate("YYYY-MM-DDT23:59:59Z"),
  "eventName": "La fête de Jimmy !",
  "eventDate": ISODate("YYYY-MM-DDT16:00:00Z")
}

Le flux RSVP

C'est là que le modèle de groupe paie vraiment et je peux construire une logique géniale.

Voici donc le flux RSVP des invités.

Le flux rsvp

Lorsque quelqu'un ouvre la page RSVP, l'application récupère d'abord son enregistrement et tous les membres de son groupe, et calcule le nombre attendu dans le groupe.

guest = db.guests.find_one({"_id": guest_id})

group_members = []
family_sum = guest["expectedGuests"]

if guest.get("groupId"):
    group_members = list(db.guests.find({
        "groupId": guest["groupId"],
        "_id": {"$ne": guest["_id"]}
    }))
    family_sum += sum(m["expectedGuests"] for m in group_members)

Le frontend affiche le nombre d'invités attendus et les membres de leur groupe. Lorsqu'ils soumettent, l'application compare ce qu'ils ont saisi au total attendu.

Si les chiffres correspondent, c'est génial, tout le monde dans le groupe vient. Une mise à jour en bloc marque tout le groupe comme coming.

Si l'invité entre moins de personnes que prévu, une boîte de dialogue demande à son partenaire, ou à qui dans le groupe, ne vient pas. Attendu 2 mais vous avez dit 1. Lisa ne vient pas ? Et s'ils disent plus que prévu, l'application vérifie à nouveau Attendu 2 mais vous avez dit 3. Vous en êtes sûr ?

La mise à jour en bloc est une ligne.

db.guests.update_many(
    {"groupId": guest["groupId"], "_id": {"$ne": guest_id}},
    {"$set": {"status": "coming", "numGuests": 0, "rsvpDate": datetime.utcnow()}}
)

Le tableau de bord admin

C'est là que je passe mon temps dans l'application, à vérifier qui vient et qui n'a même pas encore connecté. Plus nous approchons de la date de la fête, plus je passe de temps à vérifier cette page. J'ai probablement besoin de construire un système de notification qui fait cela pour moi.

L'aperçu me donne les chiffres qui m'intéressent, les invités invités, le nombre total d'invités qui viennent, les allergies. Tout cela à partir d'une seule agrégation de base de données.

stats = db.guests.aggregate([
    {"$group": {
        "_id": None,
        "totalInvited": {"$sum": 1},
        "totalComing": {"$sum": {"$cond": [{"$eq": ["$status", "coming"]}, 1, 0]}},
        "totalGuests": {"$sum": {"$cond": [{"$eq": ["$status", "coming"]}, "$numGuests", 0]}},
        "totalExpected": {"$sum": "$expectedGuests"},
        "withAllergies": {"$sum": {"$cond": [
            {"$and": [{"$eq": ["$status", "coming"]}, {"$ne": ["$dietary", ""]}]},
            1, 0
        ]}},
    }}
])

Ensuite, il y a la page de liste des invités, c'est là que j'ajoute des personnes, génère des jetons, définis le nombre d'invités attendus, connecte à un groupe et plus encore.

La partie admin comprend également un résumé des personnes qui ont RSVPé et un résumé de toutes les préférences alimentaires et allergies. Pas si intéressant, juste une requête d'agrégation directe vers MongoDB comme ci-dessus.

Suivi de l'activité

J'ai décidé que j'avais besoin de suivre si mes invités avaient au moins connecté et quand ils ont accédé à la page pour la dernière fois. Je l'ai fait pour que lorsque la fête approche, je puisse voir si les personnes qui n'ont pas RSVPé ont au moins connecté une fois. J'ai donc ajouté des horodatages lastLogin et lastAccess. Écrire dans la base de données de manière synchrone à chaque appel API semblait ajouter de la latence.

J'ai donc ajouté une file d'attente SQS où une fonction Lambda séparée peut effectuer des opérations par lots vers la base de données. Certains événements dans la page ajoutent un message à une file d'attente. Si la publication SQS échoue, l'appel API fonctionne toujours. Le suivi est du mieux essayé, ce dont j'ai besoin pour "ont-ils au moins ouvert la page".

Autorisation avec PDP et PEP

J'ai couvert le modèle PDP/PEP et la signature JWT plus tôt. Voici comment les deux autorisateurs correspondent aux points de terminaison API :

  • GET /rsvp, PUT /rsvp utilisent l'autorisateur invité (n'importe quel JWT valide passe)
  • GET /admin/guests, POST /admin/guests, etc. utilisent l'autorisateur admin (vérifie que isAdmin est vrai)
  • POST /auth/login n'utilise aucun autorisateur (public)

Mise à l'échelle pour de vrais événements

Cela fonctionne pour ma fête de 50 personnes. Mais, cela pourrait-il s'étendre à une conférence de 10 000 personnes ? Honnêtement, oui, cela pourrait, serverless s'étend bien et le modèle de données MongoDB semble solide. Bien sûr, il y aurait besoin de quelques ajustements mais la plupart du travail est déjà fait.

Plus d'invités signifie simplement plus de documents dans MongoDB. Les index couvrent déjà les modèles de requête, et à cette échelle, la pagination skip et limit fonctionne bien. Si j'ai besoin d'une inscription publique au lieu de codes d'invitation, je remplace par Cognito User Pools, les autorisateurs ne s'en soucient pas vraiment, il vérifiera juste le JWT.

Pour les événements payants, je mettrais Stripe Checkout devant l'inscription. Un Webhook déclenche le paiement, le backend crée l'enregistrement de l'invité, envoie une confirmation, la page RSVP devient une page de billet.

Transformer cela en une solution SaaS pour gérer plusieurs locataires et événements, j'ajouterais un ID de locataire et un ID d'événement au modèle de données. Modifier un peu les requêtes et ajouter des fonctionnalités supplémentaires, mais la plupart de la logique est là et est faite.

Mots finaux

Un petit projet de passe-temps qui a commencé comme "Je ne veux pas suivre les RSVP dans les chats de groupe" est devenu la base d'une plateforme d'événements serverless complète. MongoDB gère les données, Lambda garde le backend serverless, et le modèle PDP/PEP me donne l'autorisation dont j'ai besoin.

Le code est disponible sur Serverless Handbook.

Découvrez mes autres articles sur jimmydqv.com et suivez-moi sur LinkedIn pour plus de contenu serverless.

Maintenant, allez construire !