Skip to content

Secret Santa Groups

The Secret Santa Groups feature enables users to organize gift exchange events where participants are randomly matched to give gifts to each other anonymously.

As a group organizer,
I want to create a gift exchange group, invite participants, and automatically assign matches,
So that everyone knows who to buy a gift for without revealing the pairings to others.

  • ✅ User can create a group with name, budget, date, and rules
  • ✅ User can upload a group image
  • ✅ User can invite participants by phone number or shareable link
  • ✅ User can generate a Secret Santa raffle (random matching)
  • ✅ Group admin can view all members and pending invitations
  • ✅ Non-admin members can only see their own match
  • ✅ User can view their match’s wishlist
  • ✅ User can leave a group they didn’t create
  • ✅ Admin can delete members or the entire group

Inputs:

  • Group name (string, required)
  • Budget amount (number, required)
  • Event date (date, required)
  • Rules/description (string, optional)
  • Group image (file, optional)
  • Participant phone numbers or emails

Outputs:

  • Created group with unique ID
  • Shareable invitation link
  • Raffle results (matches assigned to each member)
  • Match’s wishlist and profile information
User clicks "Create Group"
Fill form:
- Name: "Office Secret Santa"
- Budget: $50
- Date: Dec 25, 2025
- Rules: "Max $50, no gag gifts"
Upload group image (optional)
Submit form
POST /groups
├─ Success ────────────────────┐
│ │
│ ▼
│ Store group in Redux
│ │
│ ▼
│ Navigate to /group/:id
│ │
│ ▼
│ Show "Invite Participants" prompt
└─ Error ──────────────────────┐
Show error toast
User retries
Admin clicks "Invite Participants"
├─ Option 1: Manual entry ─────┐
│ │
│ ▼
│ Enter phone numbers
│ (one per line or comma-separated)
│ │
│ ▼
│ POST /groups/:id/invite
│ │
│ ▼
│ Backend sends SMS/email invites
│ │
│ ▼
│ Show "X invitations sent" toast
└─ Option 2: Shareable link ───┐
Generate link: /invite/:sharedId
Copy link to clipboard
Share via WhatsApp, email, etc.
Invitee receives SMS/email or clicks link
Opens /invite/:sharedId
├─ User logged in ─────────────┐
│ │
│ ▼
│ Show group preview
│ (name, image, admin, date)
│ │
│ ▼
│ Click "Join Group"
│ │
│ ▼
│ POST /groups/shared/:sharedId/join
│ │
│ ▼
│ Add user to group members
│ │
│ ▼
│ Navigate to /group/:id
└─ User not logged in ─────────┐
Redirect to /auth/login
After login, return to /invite/:sharedId
Admin clicks "Generate Raffle"
Show confirmation modal:
"Are you sure? This cannot be undone."
User confirms
POST /groups/:id/raffle
├─ Backend algorithm ──────────┐
│ │
│ ▼
│ Shuffle members randomly
│ │
│ ▼
│ Assign each member a unique match
│ (no self-matches, no duplicates)
│ │
│ ▼
│ Store matches in database
│ │
│ ▼
│ Send notifications to all members
├─ Success ────────────────────┐
│ │
│ ▼
│ Update group.isSorted = 1
│ │
│ ▼
│ Refresh Redux state
│ │
│ ▼
│ Show "Raffle complete!" toast
│ │
│ ▼
│ Reveal "View Match" button
└─ Error (e.g., < 2 members) ─┐
Show error toast
"Need at least 2 members"
User clicks "View Match"
Show match profile:
- Name (e.g., "Your match: John Doe")
- Profile photo
- Wishlist link
User clicks "View Wishlist"
Navigate to /group/:id/wishlists/:wishlistId
Display match's wishlist items
User browses items and purchase links
  1. Admin leaves group: Group ownership transfers to oldest member
  2. Last member leaves: Group is automatically deleted
  3. Raffle with 1 member: Show error “Need at least 2 members”
  4. User tries to view match before raffle: Show “Raffle not generated yet”
  5. User declines invitation: Invitation removed from pending list
  6. Duplicate phone number invitation: Backend prevents duplicate; shows error

Request:

POST /groups
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "Office Secret Santa",
"budget": 50,
"date": "2025-12-25",
"rules": "Max $50, no gag gifts"
}

Response:

{
"success": true,
"group": {
"id": 123,
"name": "Office Secret Santa",
"budget": 50,
"date": "2025-12-25",
"rules": "Max $50, no gag gifts",
"adminId": 1,
"admin": {
"id": 1,
"name": "Jane Doe",
"profilePhoto": "https://cdn.rnb.la/..."
},
"image": null,
"imageBlurhash": null,
"isSorted": 0,
"match": null,
"members": [],
"invitations": [],
"sharedId": "abc123xyz",
"createdAt": "2025-10-29T12:00:00Z"
}
}

Request:

POST /groups/123/invite
Authorization: Bearer {token}
Content-Type: application/json
[
{ "name": "John Smith", "phone": "+1234567890" },
{ "name": "Alice Brown", "phone": "+0987654321" }
]

Response:

{
"success": true,
"invitations": [
{
"id": 1,
"userName": "John Smith",
"userPhone": "+1234567890",
"userProfilePhoto": null
},
{
"id": 2,
"userName": "Alice Brown",
"userPhone": "+0987654321",
"userProfilePhoto": null
}
]
}

Request:

POST /groups/123/raffle
Authorization: Bearer {token}

Response:

{
"success": true,
"message": "Raffle generated successfully"
}

After raffle, GET /groups/123 returns group with isSorted: 1 and match populated.

Response after raffle:

{
"success": true,
"group": {
"id": 123,
"isSorted": 1,
"match": {
"id": 456,
"userId": 5,
"userName": "John Smith",
"userProfilePhoto": "https://cdn.rnb.la/..."
},
...
}
}
  • Validation: Form validates before submission (required fields, date in future)
  • Network timeout: Show toast “Request timeout. Please try again.”
  • Duplicate group name: Backend allows; no uniqueness constraint
  • 400 Bad Request: “Invalid data. Check your inputs.”
  • 403 Forbidden: “You don’t have permission to perform this action.”
  • 404 Not Found: “Group not found.”
  • 409 Conflict: “Raffle already generated.”
  • 500 Server Error: “Something went wrong. Please try again later.”
  • Create group: Manual retry (user clicks submit again)
  • Generate raffle: One-time action; no automatic retry
  • Fetch group: Automatic retry with exponential backoff (axios-retry)
  • group.created: Incremented when group is created
  • group.raffle_generated: Incremented when raffle is performed
  • group.member_joined: Incremented when user joins group
  • group.member_left: Incremented when user leaves group
console.log('Group created:', groupId);
console.log('Raffle generated for group:', groupId);
Sentry.addBreadcrumb({
category: 'group',
message: 'User viewed match',
data: { groupId, matchUserId },
});

Sentry tracks transaction duration for:

  • POST /groups (group creation)
  • POST /groups/:id/raffle (raffle generation)
  • GET /groups/:id (fetch group with members)

Assumption: No feature flags currently implemented. Consider adding for:

  • Beta testing raffle algorithm improvements
  • A/B testing invitation flows (SMS vs. link)
  • Database: Groups table must have sharedId column (added in migration v2.3)
  • Data: Existing groups without sharedId get generated on first access
src/sections/grupo/create-group-view.tsx
'use client';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { groupsHandlers } from 'src/services/handlers/groups/groupsHandlers';
import { useRouter } from 'next/navigation';
import { paths } from 'src/routes/paths';
import { toast } from 'react-toastify';
const schema = yup.object({
name: yup.string().required('Name is required'),
budget: yup.number().positive().required('Budget is required'),
date: yup.date().min(new Date(), 'Date must be in the future').required(),
rules: yup.string(),
});
export default function CreateGroupView() {
const router = useRouter();
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
resolver: yupResolver(schema),
});
const onSubmit = async (data) => {
try {
const result = await groupsHandlers.createNewGroup(data);
toast.success('Group created successfully!');
router.push(paths.home.group.main(result.group.id));
} catch (error) {
toast.error('Failed to create group');
console.error(error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Group name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('budget')} type="number" placeholder="Budget" />
{errors.budget && <span>{errors.budget.message}</span>}
<input {...register('date')} type="date" />
{errors.date && <span>{errors.date.message}</span>}
<textarea {...register('rules')} placeholder="Rules (optional)" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Group'}
</button>
</form>
);
}