Membership Registration

1. Overview

The Membership Registration workflow enables users to apply for membership in an organization. It supports various membership types with different pricing tiers, family memberships with multiple linked persons, and a process-driven question workflow that adapts based on user responses.

Key Features:

  • Multi-person Registration - Register multiple family members in single application

  • Process-Driven Workflow - Sequential question-based forms with back navigation

  • Dynamic Questions - Questions adapt based on membership type and previous answers

  • Payment Integration - External payment gateway integration

  • Resume Capability - Resume incomplete registrations from any step

2. Membership Concepts

2.1. Membership Entities

The membership system uses three key entity types:

membership-entities

MembershipType

Defines the category of membership (Individual, Family, Junior, Student, etc.)

MembershipPeriod

Defines an active registration period for a specific membership type:

  • Year or season (e.g., "2024 Season")

  • Start and end dates for registration

  • Pricing information

  • Status (open/closed)

Membership

An individual membership instance:

  • Links a Person to a MembershipPeriod

  • Tracks registration status

  • Stores payment information

  • Records membership details

2.2. Pricing Tiers

Membership pricing varies based on multiple criteria:

Criteria Examples

Number of Persons

Single person vs. family of 4

Age Groups

Adult (18+), Junior (under 18), Senior (65+)

Registration Timing

Early bird, standard, late registration

Member Type

New member, renewal, transfer

Additional Options

With insurance, with magazine subscription

Example Pricing Structure:

  • Individual Adult: R500

  • Family (2 adults): R800

  • Family (2 adults + 2 children): R1000

  • Junior (under 18): R300

  • Student (with proof): R350

3. Registration Workflow

3.1. High-Level Flow

membership-workflow

3.2. URL Parameters

Route Pattern:

/membership/register/:membershipPeriodId

Query Parameters:

Parameter Description Required Example

u

User key (security context)

Yes

abc123xyz

h

Hash (MD5 of secret + userKey)

Yes

5d41402abc4b2a76b9719d911017c592

Example URL:

https://app.example.com/membership/register/42?u=abc123xyz&h=5d41402abc4b2a76b9719d911017c592

3.3. Security Guard

Access to membership registration is protected by membershipGuard:

export const membershipGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
  const router = inject(Router);
  const membershipPeriodId = route.paramMap.get('membershipPeriodId');
  const pathUserKey = route.paramMap.get('u');
  const queryUserKey = route.queryParamMap.get('u');
  const userKey = pathUserKey || queryUserKey;
  const providedHash = route.queryParamMap.get('h');
  const requiresHash = route.data['requiresHash'];

  // Both userKey and hash must be present
  if (!userKey || !providedHash) {
    router.navigate(['/membership/register', membershipPeriodId], {
      queryParams: { error: 'Missing security verification parameters' }
    });
    return false;
  }

  // Verify hash
  const expectedHash = Md5.hashStr(SECRET_KEY + userKey);
  if (expectedHash !== providedHash) {
    router.navigate(['/membership/register', membershipPeriodId], {
      queryParams: { error: 'Invalid security verification' }
    });
    return false;
  }

  return true;
};

Hash Verification:

  • expectedHash = MD5(SECRET_KEY + userKey)

  • Prevents unauthorized access to membership registration

  • SECRET_KEY stored in environment configuration

  • User key uniquely identifies the registering user/organization

4. Person Selection Stage

4.1. Component: MembershipRegisterComponent

The first stage of membership registration displays linked persons and allows selection.

File: src/main/webapp/app/entities/membership/list/membership-register.ts

4.2. Loading Membership State

When the component initializes, it loads the membership form state:

private loadMembership(): void {
  this.loading = true;
  this.formService
    .getFormMembership(this.membershipPeriodId, this.userKey)
    .pipe(
      tap(formState => {
        this.formDTO = formState;
        this.linkedPersons = formState.people
          ? Array.from(formState.people)
          : [];
        this.processId = formState.processId;

        // Set selected persons based on API response
        this.selectedPersons = this.linkedPersons
          .filter(person => person.selected);

        if (formState.processState === ProcessInstanceDTO.StatusEnum.Running ||
            formState.processState === ProcessInstanceDTO.StatusEnum.Resume) {
          this.showProcessDialog();
        }
      }),
      takeUntil(this.destroy$),
      finalize(() => (this.loading = false))
    )
    .subscribe();
}

API Endpoint: GET /api/forms/membership/{periodId}?userKey={key}

Response (FormDTO):

{
  "processId": "proc-123",
  "step": 1,
  "processState": "NONE",
  "people": [
    {
      "id": 1,
      "firstName": "John",
      "lastName": "Smith",
      "dateOfBirth": "1985-03-15",
      "status": "Available",
      "canSelect": true,
      "selected": false
    },
    {
      "id": 2,
      "firstName": "Jane",
      "lastName": "Smith",
      "dateOfBirth": "1987-07-20",
      "status": "Available",
      "canSelect": true,
      "selected": false
    },
    {
      "id": 3,
      "firstName": "Billy",
      "lastName": "Smith",
      "dateOfBirth": "2010-11-03",
      "status": "Available",
      "canSelect": true,
      "selected": false
    }
  ]
}

4.3. Person Selection Table

person-table

Selection Logic:

toggleSelection(person: FormPersonDTO): void {
  // Only allow selection if canSelect is true
  if (!person.canSelect) {
    return;
  }

  if (this.isSelected(person)) {
    this.selectedPersons = this.selectedPersons.filter(p => p.id !== person.id);
    person.selected = false;
  } else {
    this.selectedPersons.push(person);
    person.selected = true;
  }
}

Person Status:

Status Meaning Can Select?

Available

Can be added to membership

Yes

Registered

Already has active membership

No

Pending Payment

Registration pending payment

No

4.4. Resume Incomplete Registration

If a process is already running, the user is prompted to resume or start fresh:

showProcessDialog(): void {
  this.sharedDialogService
    .show({
      header: 'Existing Registration',
      message: 'A previous registration attempt has been detected. ' +
               'Would you like to continue with that, or start fresh?',
      buttons: [
        {
          label: 'Start Fresh',
          action: ProcessAction.RESET,
          class: 'p-button-success',
        },
        {
          label: 'Resume',
          action: ProcessAction.RESUME,
          class: 'p-button-primary',
        },
      ],
    })
    .pipe(takeUntil(this.destroy$))
    .subscribe(result => {
      if (result === ProcessAction.RESET || result === ProcessAction.RESUME) {
        this.handleProcessAction(result as ProcessAction);
      }
    });
}
resume-dialog

Process Actions:

handleProcessAction(action: ProcessAction): void {
  if (!this.formDTO) return;

  this.formService[action === ProcessAction.RESET ? 'reset' : 'resume'](
    this.formDTO.processId
  )
    .pipe(takeUntil(this.destroy$))
    .subscribe(_formDTO => {
      if (_formDTO.processState === ProcessInstanceDTO.StatusEnum.Running ||
          _formDTO.processState === ProcessInstanceDTO.StatusEnum.Resume) {
        this.updateFormWithData(_formDTO);
      }
    });
}

API Endpoints:

  • Reset: POST /api/forms/processes/{processId}/reset

  • Resume: POST /api/forms/processes/{processId}/resume

4.5. Submitting Person Selection

register(): void {
  this.loading = true;
  const selectedPersonsDTO = this.linkedPersons
    .filter(person => person.selected)
    .map(person => ({ id: person.id }) as FormPersonDTO);

  this.formService
    .next({
      processId: this.processId,
      step: this.formDTO.step,
      people: selectedPersonsDTO,
    })
    .pipe(
      tap(response => this.handleNextFormResponse(response)),
      takeUntil(this.destroy$),
      finalize(() => {
        this.loading = false;
      })
    )
    .subscribe({
      next: () => {
        this.showQuestionBox = true;
      },
      error: error => {
        this.showQuestionBox = false;
        this.handleError(error);
      }
    });
}

API Endpoint: POST /api/forms/next

Request Body:

{
  "processId": "proc-123",
  "step": 1,
  "people": [
    { "id": 1 },
    { "id": 3 }
  ]
}

5. Process-Driven Questions

5.1. Question Component

After person selection, the application enters a process-driven question workflow.

Component: QuestionDialogComponent

File: src/main/webapp/app/entities/membership/questions/membership-question.ts

5.2. Question Types

The system supports multiple question types with specific behaviors and validation rules:

Type Code Name UI Component Validation

TXT

Text Input

Input field per person

Required/Optional text

NUM

Number Input

Number field per person

Numeric validation

DRP

Dropdown

Select menu per person

Required selection

BCB

Binary Checkbox

Checkbox per person

Yes (1) / No (0)

ITC

I Accept (Terms)

Master checkbox

All must accept (1)

ONE

Select One

Radio buttons

Exactly one person must be selected

RDO

Radio Options

Radio per person

Required selection per person

SEL

Multi-Select

Checkbox group per person

Multiple selections allowed

5.2.1. TXT - Text Input

Purpose: Collect free-form text responses from each participant.

UI Behavior:

  • One text input field displayed per person

  • Optional placeholder text

  • Character limit validation if specified

Data Format:

  • Answer stored as plain text string

  • Empty string for no answer

Example Questions:

  • "What is your emergency contact number?"

  • "Please specify your dietary requirements"

  • "Enter your team name"

Validation:

  • Required questions: Answer must be non-empty

  • Optional questions: Empty answers allowed

5.2.2. SEL - Multi-Select (Dropdown)

Purpose: Present predefined options to user in a dropdown list.

UI Behavior:

  • Dropdown select field per person

  • Options loaded from options array in FormDTO

  • First option often blank or "Please select…​"

Data Format:

  • Answer is the index of the selected option (0-based)

  • Stored as string number: "0", "1", "2", etc.

Example Questions:

  • "What is your T-shirt size?" - Options: ["S", "M", "L", "XL", "XXL"]

  • "Select your preferred race distance" - Options: ["5km", "10km", "21km"]

Validation:

  • Must select a valid option index

  • Cannot submit with default/blank selection if required

5.2.3. BCB - Binary Checkbox

Purpose: Yes/No question for each participant independently.

UI Behavior:

  • Individual checkbox per person

  • Unchecked = "0", Checked = "1"

  • No "select all" option (each person independent)

Data Format:

  • Answer: "0" (unchecked) or "1" (checked)

  • Defaults to "0" if not answered

Example Questions:

  • "Does anyone have any medical conditions?"

  • "Will you require transport?"

  • "Do you have a valid racing license?"

Validation:

  • Always valid (can be checked or unchecked)

  • If required, at least one person should have answer

5.2.4. ONE - Select One Person

Purpose: Designate exactly one person from the group.

UI Behavior:

  • Radio buttons (only one can be selected)

  • All participants displayed

  • Exactly one must be selected

Data Format:

  • Selected person: answer = "1"

  • All others: answer = "0"

  • Only one person can have answer = "1"

Example Questions:

  • "Who will be the primary contact person?"

  • "Who is the team captain?"

  • "Which participant is the account holder?"

Validation:

  • Required: Exactly one person must have answer = "1"

  • Optional: Zero or one person can have answer = "1"

5.2.5. ITC - I Accept Terms

Purpose: Terms and conditions acceptance for all participants.

UI Behavior:

  • Master "Accept for all members" checkbox at top

  • Individual acceptance checkbox per person below

  • Checking master checkbox sets all individual checkboxes to "1"

  • All individuals must be checked to proceed

Data Format:

  • Each person must have answer = "1"

  • Any person with answer = "0" blocks progression

Example Questions:

  • "I accept the terms and conditions of membership"

  • "I acknowledge the event indemnity form"

  • "I agree to the privacy policy"

Validation:

  • Strict validation: All people must have answer = "1"

  • Cannot proceed if any person has answer = "0"

  • Used for legal/compliance requirements

Implementation:

// Master checkbox behavior
onMasterCheckChange(checked: boolean): void {
  this.people.forEach(person => {
    person.answer = checked ? '1' : '0';
  });
}

// Validation
isValid(): boolean {
  if (this.questionData.questionType === 'ITC') {
    return this.people.every(person => person.answer === '1');
  }
  // ... other validations
}

5.3. Question Flow

question-flow

5.4. Question DTO Structure

{
  "processId": "proc-123",
  "step": 2,
  "processState": "RUNNING",
  "question": "Does anyone have any medical conditions?",
  "questionType": "BCB",
  "required": true,
  "options": null,
  "people": [
    {
      "id": 1,
      "firstName": "John",
      "lastName": "Smith",
      "answer": null,
      "hide": false
    },
    {
      "id": 3,
      "firstName": "Billy",
      "lastName": "Smith",
      "answer": null,
      "hide": false
    }
  ],
  "paymentUrl": null
}

5.5. Question UI Examples

5.5.1. Text Input Question

text-question

5.5.2. Binary Checkbox Question

checkbox-question

5.5.3. Select One Question

radio-question

5.5.4. Terms and Conditions Question

terms-question

5.6. Answer Submission

next() {
  this.loading = true;
  this.selectedPeople = this.people.map(_person => ({
    answer: _person.answer || '0',
    id: _person.id,
  }));

  this.formService
    .next({
      processId: this.questionData.processId,
      step: this.questionData.step,
      people: this.selectedPeople,
    })
    .pipe(finalize(() => (this.loading = false)))
    .subscribe(_response => {
      this.questionData = _response;
      this.people = Array.from(this.questionData?.people || [])
        .map(p => ({
          ...p,
          answer: p.answer || '',
        })) || [];
    });
}

Request Body:

{
  "processId": "proc-123",
  "step": 2,
  "people": [
    { "id": 1, "answer": "0" },
    { "id": 3, "answer": "1" }
  ]
}

5.7. Answer Validation

isValid(): boolean {
  if (!this.questionData.required) {
    return true;
  }

  if (this.questionData.questionType === 'ITC') {
    // For ITC (Terms), all people must have answer '1' (checked)
    return this.people.every(person => person.answer === '1');
  }

  return this.people.every(person => {
    if (person.hide) {
      return true;
    }

    if (this.questionData.questionType === 'BCB') {
      return true; // Binary checkboxes are always valid
    }

    if (this.questionData.questionType === 'ONE') {
      // For required ONE type, at least one person should have answer '1'
      return this.people.some(p => p.answer === '1');
    }

    return person.answer !== null &&
           person.answer !== undefined &&
           person.answer !== '';
  });
}

Validation Rules:

Question Type Validation Rule Error Condition

ITC

All must accept

Any person has answer ≠ '1'

ONE

Exactly one selected

No person has answer = '1'

TXT, NUM, DRP, RDO

Answer required (if required=true)

Any person has empty answer

BCB

Always valid

(No error condition)

5.8. Master Checkbox (ITC Questions)

For "I Accept" questions, a master checkbox allows accepting for all people at once:

handleMasterCheckbox(checked: boolean): void {
  // Update all people's answers when master checkbox changes
  this.people.forEach(person => {
    person.answer = checked ? '1' : '0';
  });
}

UI Implementation:

<div *ngIf="questionData.questionType === 'ITC'">
  <p-checkbox
    [(ngModel)]="masterCheckbox"
    (onChange)="handleMasterCheckbox(masterCheckbox)"
    [binary]="true"
    label="Accept for all members">
  </p-checkbox>

  <div *ngFor="let person of people">
    <p-checkbox
      [(ngModel)]="person.answer"
      [binary]="true"
      [label]="person.firstName + ' ' + person.lastName">
    </p-checkbox>
  </div>
</div>

6. Payment Integration

6.1. Payment Summary

When all questions are answered, the process completes and displays a payment summary:

payment-summary

6.2. Payment Redirect

pay() {
  if (this.questionData.paymentUrl) {
    this.messageService.add({
      severity: 'info',
      summary: 'Payment Portal',
      detail: 'Redirecting to payment portal...',
      sticky: true,
      life: 5000,
    });

    setTimeout(() => {
      if (this.questionData.paymentUrl) {
        window.location.href = this.questionData.paymentUrl;
      }
    }, 5000);
  }
}

Payment URL Format:

https://payment.gateway.com/pay?reference=REF123&amount=800.00&return=https://app.example.com/membership/payment-return

Flow:

  1. User clicks "Pay Now"

  2. Show redirect message (5 seconds)

  3. Redirect to external payment gateway

  4. User completes payment

  5. Gateway redirects back to return URL

  6. Application updates membership status

6.3. Payment Return Handling

Return URL: /membership/payment-return

Query Parameters:

  • reference - Payment reference

  • status - Payment status (success, failed, cancelled)

  • transactionId - Payment transaction ID

Status Updates:

Status Description Next Step

success

Payment successful

Activate membership, send confirmation

failed

Payment failed

Show error, allow retry

cancelled

User cancelled payment

Return to registration, allow resume

7. Process State Management

7.1. Process States

process-states

Process State Enum:

export enum ProcessState {
  NONE = 'NONE',
  RUNNING = 'RUNNING',
  SUSPENDED = 'SUSPENDED',
  DONE = 'DONE',
  PENDING_PAYMENT = 'PENDING_PAYMENT',
  COMPLETED = 'COMPLETED',
  CANCELLED = 'CANCELLED',
  RESUME = 'RESUME'
}

7.2. Form Service

The FormService manages process state and progression:

@Injectable({ providedIn: 'root' })
export class FormService {
  private formResourceService = inject(FormResourceService);
  private blobUtils = inject(BlobUtilsService);

  /**
   * Get form membership details
   */
  getFormMembership(membershipPeriodId: number, userKey: string): Observable<FormDTO> {
    return this.formResourceService
      .getFormMembership(membershipPeriodId, userKey)
      .pipe(
        mergeMap(response => this.blobUtils.parseBlob<FormDTO>(response)),
        map(formDTO => this.convertToExtendedFormDTO(formDTO))
      );
  }

  /**
   * Reset an existing form process
   */
  reset(processId: string): Observable<FormDTO> {
    return this.formResourceService.reset(processId)
      .pipe(
        mergeMap(response => this.blobUtils.parseBlob<FormDTO>(response)),
        map(formDTO => this.convertToExtendedFormDTO(formDTO))
      );
  }

  /**
   * Resume an existing form process
   */
  resume(processId: string): Observable<FormDTO> {
    return this.formResourceService.resume(processId)
      .pipe(
        mergeMap(response => this.blobUtils.parseBlob<FormDTO>(response)),
        map(formDTO => this.convertToExtendedFormDTO(formDTO))
      );
  }

  /**
   * Submit form data and get next step
   */
  next(data: any): Observable<FormDTO> {
    const headers = TimeoutInterceptor.getHeadersWithTimeout(15000);

    const options = {
      httpHeaderAccept: '*/*' as const,
      headers,
    };

    return this.formResourceService.next(data, 'body', false, options)
      .pipe(
        mergeMap(response => this.blobUtils.parseBlob<FormDTO>(response)),
        map(formDTO => this.convertToExtendedFormDTO(formDTO))
      );
  }
}

7.3. API Endpoints

Method Endpoint Purpose Response

GET

/api/forms/membership/{periodId}?userKey={key}

Get membership form state

FormDTO with people and process state

POST

/api/forms/next

Submit form step, get next

FormDTO with next question or completion

POST

/api/forms/processes/{id}/reset

Reset process to start

FormDTO with initial state

POST

/api/forms/processes/{id}/resume

Resume suspended process

FormDTO with current step

8. Session Management

8.1. Session Service

The SessionService manages user session data:

@Injectable({ providedIn: 'root' })
export class SessionService {
  private userKeySubject = new BehaviorSubject<string>('');
  private membershipIdSubject = new BehaviorSubject<number | null>(null);

  userKey$ = this.userKeySubject.asObservable();
  membershipId$ = this.membershipIdSubject.asObservable();

  setUserKey(userKey: string): string {
    this.userKeySubject.next(userKey);
    sessionStorage.setItem('userKey', userKey);
    return userKey;
  }

  getUserKey(): string {
    return this.userKeySubject.value ||
           sessionStorage.getItem('userKey') || '';
  }

  setMembershipId(membershipId: number): number {
    this.membershipIdSubject.next(membershipId);
    sessionStorage.setItem('membershipId', membershipId.toString());
    return membershipId;
  }

  getMembershipId(): number | null {
    return this.membershipIdSubject.value ||
           parseInt(sessionStorage.getItem('membershipId') || '0', 10) ||
           null;
  }
}

Storage:

  • userKey - Stored in sessionStorage

  • membershipId - Stored in sessionStorage

  • Persists across page refreshes within same browser session

  • Cleared when browser is closed

9. Component Interactions

component-interaction

10. Error Handling

10.1. Access Denied

ngOnInit(): void {
  // Check for error in query params
  const error = this.route.snapshot.queryParamMap.get('error');
  if (error) {
    this.accessDenied = true;
    return;
  }

  // Check for access denied from route data
  if (this.route.snapshot.data['accessDenied']) {
    this.accessDenied = true;
    return;
  }

  this.initializeComponentData();
}

Access Denied Scenarios:

  • Missing user key (u parameter)

  • Missing or invalid hash (h parameter)

  • Expired or invalid membership period

  • User not authorized for organization

10.2. Form Validation Errors

private handleError(error: any): void {
  this.messageService.add({
    severity: 'error',
    summary: 'Error',
    detail: error.message || 'An error occurred during the operation',
    life: 5000,
  });
}

10.3. Network Timeouts

Form submissions use extended timeouts for long-running operations:

next(data: any): Observable<FormDTO> {
  const headers = TimeoutInterceptor.getHeadersWithTimeout(15000);

  const options = {
    httpHeaderAccept: '*/*' as const,
    headers,
  };

  return this.formResourceService.next(data, 'body', false, options)
    .pipe(
      mergeMap(response => this.blobUtils.parseBlob<FormDTO>(response)),
      map(formDTO => this.convertToExtendedFormDTO(formDTO))
    );
}

Timeout: 15 seconds for question submission (allows backend processing time)

11. Integration with Process Entities

The membership registration workflow integrates with the Process entity system:

  • ProcessDefinition - Defines the question workflow template

  • ProcessInstance - Tracks individual registration progress

  • ProcessStep - Each question is a step in the process

  • ProcessStepAnswer - Stores user answers for each step

See Process Flow Integration for complete details on the process engine.

12. Best Practices

12.1. User Experience

Progress Indication:

  • Show current step number and total steps

  • Display progress bar during questions

  • Provide clear "Back" navigation

Validation Feedback:

  • Client-side validation for immediate feedback

  • Clear error messages for each field

  • Highlight invalid fields visually

Mobile Responsiveness:

  • Responsive tables for person selection

  • Touch-friendly checkboxes and radio buttons

  • Optimized layout for mobile screens

12.2. Performance

Lazy Loading:

  • Load question component only when needed

  • Defer payment gateway scripts until payment stage

Caching:

  • Cache membership period details

  • Store form state in sessionStorage for recovery

Optimistic UI:

  • Show loading spinners during API calls

  • Disable submit buttons to prevent double-submission

12.3. Security

User Key Validation:

  • Always validate user key and hash

  • Check expiry timestamps if applicable

  • Log access attempts for audit

CSRF Protection:

  • Include CSRF tokens in form submissions

  • Validate origin for payment returns

Data Sanitization:

  • Sanitize all user inputs before submission

  • Validate answer formats on client and server