LinkedPerson Management

1. Overview

The LinkedPerson feature enables users to manage groups of people who have relationships with each other. This is a fundamental building block for both membership registration (family memberships) and event registration (registering multiple participants together).

Primary Use Cases:

  • Family Memberships - Parents linking children for family membership applications

  • Group Event Registration - Friends or team members registering together for events

  • Club/Team Management - Club members linking to their organization

  • Organizational Relationships - Managing people within organizational contexts

2. Concept

2.1. LinkedPerson Entity

A LinkedPerson represents a relationship between two Person entities within an organizational context:

export interface ILinkedPerson {
  id: number;
  type?: keyof typeof LinkedPersonType | null;
  principal?: Pick<IPerson, 'id' | 'name'> | null;
  linkedPerson?: IPerson | NewPerson | null;
  firstName?: string;
  lastName?: string;
}

Key Properties:

  • principal - The primary person (e.g., parent, team leader, account holder)

  • linkedPerson - The secondary person (e.g., child, team member, dependent)

  • type - The relationship type (FAMILY, FRIEND, CLUB_MEMBER, TEAM_MEMBER, etc.)

2.2. Person Entity

Person entities represent individuals with identity information:

Core Identity Fields:

  • identityType - NATIONAL (ID number), PASSPORT, OTHER

  • identityNumber - ID/passport number

  • identityCountry - Country code for identity document

  • dateOfBirth - Birth date

  • gender - MALE, FEMALE, OTHER

Contact Fields:

  • firstName, lastName, name

  • email, contactNumber

  • school - For children/students

3. LinkedPerson Workflow

3.1. Search Workflow

The LinkedPerson workflow begins with searching for existing persons before creating new ones. This prevents duplicate person records.

linkedperson-search-flow

3.2. Search Types

Three search methods are available:

Search Type Input Required Behavior Auto-Select

ID Number

South African ID number (13 digits)

Validates format, auto-extracts DOB and gender

Yes (single match)

Date of Birth

Birth date (YYYY-MM-DD)

Broad search, often returns multiple results

No

Membership Number

Existing membership number

Looks up person via membership records

Yes (single match)

3.3. Three-Stage UI Workflow

3.3.1. Stage 1: Search Type Selection

search-selection

Component: LinkedPersonSearchComponent

selectSearch(value: SearchType): void {
  if (value === SearchType.IDNUMBER) {
    this.searchForm.patchValue({
      type: SearchType.IDNUMBER,
      dob: null,
      membershipNumber: null
    });
  } else if (value === SearchType.DOB) {
    this.searchForm.patchValue({
      type: SearchType.DOB,
      idNumber: null,
      membershipNumber: null
    });
  } else if (value === SearchType.MEMBERSHIP) {
    this.searchForm.patchValue({
      type: SearchType.MEMBERSHIP,
      idNumber: null,
      dob: null
    });
  }
}

3.3.2. Stage 2: Search Execution

Search Logic:

search(): void {
  const searchDetails = this.identityFromSearchForm();
  const searchType = this.searchForm.get('type')?.value;

  this.isSearching = true;
  this.linkedPersonStateService.clear();
  this.searchIdentityDetails = searchDetails ?? undefined;

  // Save search state
  this.linkedPersonStateService.setSearchState(searchType);

  this.searchPerson(searchDetails, searchDetails)
    .pipe(finalize(() => {
      this.isSearching = false;
      this.searchEnabled = false;
    }))
    .subscribe({
      next: persons => {
        if (persons.length === 0) {
          // Create new person
          const newPerson = this.linkedPersonStateService
            .createNewPerson(searchDetails);
          this.linkedPersonStateService.setPerson(newPerson);
        } else if (persons.length === 1 &&
                   searchType !== SearchType.DOB) {
          // Auto-select for ID/Membership searches
          this.linkedPersonStateService.setPerson(persons[0]);
        } else {
          // Show selection screen
          this.linkedPersonStateService.setPersons(persons);
        }
      },
      error: () => {
        this.linkedPersonStateService.clear();
        this.onSearchFinalize();
      }
    });
}

Search API Calls:

Search Type API Endpoint Method

ID Number

/api/people/match

POST with identityNumber

Date of Birth

/api/people/match

POST with dateOfBirth

Membership Number

/api/memberships/obfuscate/{typeId}/{number}/api/people/{id}

GET membership, then GET person

3.3.3. Stage 3: Person Selection/Creation

Three possible outcomes:

  1. No Results → Show create form with pre-populated search criteria

  2. Single Result → Auto-select (ID/Membership) or show for confirmation (DOB)

  3. Multiple Results → Display table for user selection

Selection Table (Multiple Results):

person-selection-table

Component: LinkedPersonSelectAddComponent

3.4. State Management

LinkedPerson workflow state is managed by LinkedPersonStateService:

@Injectable({ providedIn: 'root' })
export class LinkedPersonStateService {
  private personsSubject = new BehaviorSubject<IPerson[] | undefined>(undefined);
  private personSubject = new BehaviorSubject<IPerson | undefined>(undefined);
  private searchStateSubject = new BehaviorSubject<SearchType | undefined>(undefined);

  // Public observables
  persons$ = this.personsSubject.asObservable();
  person$ = this.personSubject.asObservable();
  searchState$ = this.searchStateSubject.asObservable();

  setPersons(persons: IPerson[] | undefined): void {
    this.personsSubject.next(persons);
  }

  setPerson(person: IPerson | undefined): void {
    this.personSubject.next(person);
  }

  setSearchState(searchType: SearchType | undefined): void {
    this.searchStateSubject.next(searchType);
  }

  createNewPerson(searchDetails?: IPersonIdentity): NewPerson {
    return {
      id: null,
      ...searchDetails,
    };
  }

  linkPerson(id: number, type: string, userKey: string): Observable<ILinkedPerson> {
    return this.http.post<ILinkedPerson>(
      `${this.linkEdPersonUrl}/${id}/${type}?userKey=${userKey}&organisationId=8`,
      { observe: 'response' }
    );
  }

  clear(): void {
    this.setPersons(undefined);
    this.setPerson(undefined);
    this.searchStateSubject.next(undefined);
  }
}

State Flow:

state-flow

4. Person Identity Component

4.1. Identity Form

The identity component captures and validates person identity information:

Component: Identity (implements ControlValueAccessor and Validator)

@Component({
  selector: 'identity',
  standalone: true,
  providers: [
    { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: Identity },
    { provide: NG_VALIDATORS, multi: true, useExisting: Identity }
  ]
})
export class Identity implements ControlValueAccessor, Validator {
  editForm!: FormGroup;

  initializeForm(): void {
    this.editForm = this.fb.group({
      identityType: ['', Validators.required],
      identityNumber: ['', IDNumberService.validateIdentityNumber],
      identityCountry: ['', Validators.required],
      dateOfBirth: ['', Validators.required],
      gender: ['', Validators.required],
    });
  }
}

4.2. South African ID Number Validation

ID Number Format:

  • 13 digits: YYMMDD SSSS C A Z

  • YYMMDD - Date of birth (year, month, day)

  • SSSS - Sequence number (0000-4999 = female, 5000-9999 = male)

  • C - Citizenship (0 = SA citizen, 1 = permanent resident)

  • A - Unused (always 8 before 1986, after 1986 race was removed)

  • Z - Checksum digit

Validation Service:

export class IDNumberService {
  static validate(idNumber: string): boolean {
    if (!idNumber || idNumber.length !== 13) {
      return false;
    }

    // Validate date components
    const year = parseInt(idNumber.substring(0, 2), 10);
    const month = parseInt(idNumber.substring(2, 4), 10);
    const day = parseInt(idNumber.substring(4, 6), 10);

    if (month < 1 || month > 12) return false;
    if (day < 1 || day > 31) return false;

    // Validate checksum using Luhn algorithm
    return this.validateChecksum(idNumber);
  }

  static extract(idNumber: string): IIDNumberDetails {
    const year = parseInt(idNumber.substring(0, 2), 10);
    const month = parseInt(idNumber.substring(2, 4), 10) - 1;
    const day = parseInt(idNumber.substring(4, 6), 10);
    const sequence = parseInt(idNumber.substring(6, 10), 10);

    const currentYear = new Date().getFullYear();
    const century = year > (currentYear % 100) ? 1900 : 2000;
    const fullYear = century + year;

    const birthDate = new Date(fullYear, month, day);
    const male = sequence >= 5000;

    return {
      idNumber,
      birthDate,
      male,
      valid: true
    };
  }
}

4.3. Field Locking

When searching by ID number, identity fields are locked to prevent modification:

setIdentityControlState(value: IdentityType): void {
  const identityNumberControl = this.getIdentityNumberControl();
  const identityTypeControl = this.getIdentityTypeControl();
  const dobControl = this.getDOBControl();
  const genderControl = this.getGenderControl();
  const countryControl = this.getCountryControl();

  if (value === IdentityType.NATIONAL &&
      this.searchType === SearchType.IDNUMBER &&
      identityNumberControl.value) {
    // For SA ID Number - lock all derived fields
    genderControl.disable();
    identityNumberControl.disable();
    identityTypeControl.disable();
    countryControl.setValue(this.ZA);
    countryControl.disable();

    const idDetails = this.getIDNumberDetails();
    if (idDetails.valid) {
      dobControl.disable();
      this.updateGenderDOBControls();
    } else {
      dobControl.enable();
    }
  } else {
    // For other types, enable manual input
    dobControl.enable();
    genderControl.enable();
    identityNumberControl.enable();
    countryControl.enable();
    identityTypeControl.enable();
  }
}

Field Lock Table:

Search Type ID Number DOB Gender

ID Number (valid)

🔒 Locked

🔒 Locked

🔒 Locked

ID Number (invalid)

🔒 Locked

✅ Editable

✅ Editable

Date of Birth

✅ Editable

✅ Editable

✅ Editable

Membership Number

✅ Editable

✅ Editable

✅ Editable

5. Create/Edit Person Form

5.1. Identity Type Field Locking

Based on the search method used, certain identity fields are automatically locked to prevent data inconsistency:

Field Locking Rules:

Scenario Identity Type ID Number Date of Birth Gender Country

SA ID Number (valid format)

🔒 Locked to NATIONAL

🔒 Locked

🔒 Auto-populated, locked

🔒 Auto-extracted, locked

🔒 Locked to ZA

SA ID Number (invalid format)

🔒 Locked to NATIONAL

🔒 Locked

✅ Editable

✅ Editable

🔒 Locked to ZA

Date of Birth search

✅ Editable

✅ Editable

🔒 Pre-filled from search

✅ Editable

✅ Editable

Membership Number search

🔒 Based on existing record

🔒 Based on existing record

🔒 Based on existing record

🔒 Based on existing record

🔒 Based on existing record

Creating new person (no search)

✅ Editable

✅ Editable

✅ Editable

✅ Editable

✅ Editable

South African ID Number Auto-Extraction:

When a valid 13-digit SA ID number is entered:

  1. Date of Birth Extraction - Digits 1-6 encode YYMMDD

    • 900515 → 1990-05-15 or 2090-05-15 (century determined by proximity to current date)

  2. Gender Extraction - Digits 7-10 encode gender sequence

    • 0000-4999 → Female

    • 5000-9999 → Male

  3. Field Locking - Extracted values become read-only

    • Identity Type locked to "South African ID"

    • Country locked to "ZA"

    • Date of Birth locked to extracted value

    • Gender locked to extracted value

Example:

ID Number: 9005150123089
  ↓
Date of Birth: 1990-05-15 (digits 1-6: 900515)
Gender: Male (digit 7-10: 0123 < 5000)
Identity Type: South African ID (locked)
Country: ZA (locked)

Field Locking Implementation:

// In Identity component
if (this.identity.identityType === 'NATIONAL' &&
    this.isValidSAIDNumber(this.identity.identityNumber)) {
  // Lock all ID-derived fields
  this.identityForm.get('identityType')?.disable();
  this.identityForm.get('identityNumber')?.disable();
  this.identityForm.get('dateOfBirth')?.disable();
  this.identityForm.get('gender')?.disable();
  this.identityForm.get('identityCountry')?.disable();
}

5.2. Form Structure

When creating or editing a person, the form combines identity fields with contact information:

person-form

5.3. Form Validation

private initializeForm(): void {
  this.editForm = this.fb.group({
    id: ['', [Validators.required]],
    firstName: ['', [
      ...this.nameValidators,
      this.restrictedNameValidator('anonymous')
    ]],
    lastName: ['', [
      ...this.nameValidators,
      this.restrictedNameValidator('user')
    ]],
    email: ['', [Validators.email, Validators.maxLength(254)]],
    contactNumber: [''],
    identity: [null, [Validators.required]],
    school: [null],
  });
}

nameValidators = [
  Validators.required,
  Validators.minLength(2),
  Validators.maxLength(50),
  this.noSpecialCharactersValidator()
];

restrictedNameValidator(restrictedName: string) {
  return (control: FormControl): { [key: string]: boolean } | null => {
    return control.value?.toLowerCase() === restrictedName
      ? { restrictedName: true }
      : null;
  };
}

noSpecialCharactersValidator() {
  return (control: FormControl): { [key: string]: boolean } | null => {
    return control.value?.includes('*')
      ? { specialCharacters: true }
      : null;
  };
}

Validation Rules:

Field Rules Error Messages

First Name

Required, 2-50 chars, no asterisks, not "anonymous"

required, minlength, specialCharacters, restrictedName

Last Name

Required, 2-50 chars, no asterisks, not "user"

required, minlength, specialCharacters, restrictedName

Email

Valid email format, max 254 chars

email, maxlength

Contact Number

Optional

-

Identity

Required, must pass identity validation

required, identity validation errors

School

Optional

-

5.4. Create vs Update

Create Person:

createPerson(person: IPerson, userKey: string): Observable<IPerson> {
  return this.http.post<IPerson>(
    `${this.peopleUrl}?userKey=${userKey}`,
    person
  ).pipe(map(response => response));
}

Update Person:

updatePerson(person: IPerson, userKey: string): Observable<IPerson> {
  return this.personResourceService
    .updatePerson(person.id ?? 0, person as any, 8, userKey, 'response')
    .pipe(mergeMap(response => this.blobUtils.parseBlob<IPerson>(response)));
}

6. Linking Persons

After selecting or creating a person, they must be linked to the principal:

linkPerson(id: number, type: string, userKey: string): Observable<ILinkedPerson> {
  return this.http.post<ILinkedPerson>(
    `${this.linkEdPersonUrl}/${id}/${type}?userKey=${userKey}&organisationId=8`,
    { observe: 'response' }
  );
}

API Endpoint: POST /api/linked-people/link/{personId}/{type}

Parameters:

  • personId - ID of person to link

  • type - Relationship type (GENERAL, FAMILY, FRIEND, etc.)

  • userKey - Security context for current user

  • organisationId - Organization context

6.2. LinkedPerson Types

export enum LinkedPersonType {
  GENERAL = 'GENERAL',
  FAMILY = 'FAMILY',
  FRIEND = 'FRIEND',
  CLUB_MEMBER = 'CLUB_MEMBER',
  TEAM_MEMBER = 'TEAM_MEMBER',
  COLLEAGUE = 'COLLEAGUE',
  OTHER = 'OTHER'
}

Usage Examples:

  • Membership Registration - Parent links children as FAMILY type

  • Event Registration - Friends register together as FRIEND type

  • Club Management - Members linked as CLUB_MEMBER type

  • Team Events - Athletes linked as TEAM_MEMBER type

7. UI Components

7.1. Component Hierarchy

component-hierarchy

7.2. Routing

Routes:

export const linkedPersonRoute: Routes = [
  {
    path: 'search',
    component: LinkedPersonSearchComponent,
    canActivate: [UserRouteAccessService],
  },
  {
    path: 'select-add',
    component: LinkedPersonSelectAddComponent,
    canActivate: [UserRouteAccessService],
  },
];

Navigation Flow:

/linked-person/search → (search) → /linked-person/select-add → (link) → /membership/register

7.3. Component Files

Search Component:

  • src/main/webapp/app/entities/linked-person/search/search.ts

  • src/main/webapp/app/entities/linked-person/search/search.html

Select/Add Component:

  • src/main/webapp/app/entities/linked-person/select-add/select-add.ts

  • src/main/webapp/app/entities/linked-person/select-add/select-add.html

Identity Component:

  • src/main/webapp/app/entities/linked-person/identity/identity.ts

  • src/main/webapp/app/entities/linked-person/identity/identity.html

Services:

  • src/main/webapp/app/core/services/linked-person-state.service.ts

  • src/main/webapp/app/core/services/people.service.ts

  • src/main/webapp/app/entities/admin-service/person/service/person.service.ts

8. Integration with Membership Registration

LinkedPerson management integrates with membership registration workflows:

  1. User selects membership type → Navigates to /membership/register

  2. User adds family members → Navigates to /linked-person/search

  3. User searches/creates persons → Workflow managed by LinkedPersonStateService

  4. Persons are linked → Returns to /membership/register

  5. Membership created with linked persons

See Membership Registration for full workflow details.

9. Integration with Event Registration

LinkedPerson management also supports event registration:

  1. User selects event → Navigates to /registration/event

  2. User adds participants → Navigates to /linked-person/search

  3. User searches/creates persons → Same workflow as membership

  4. Persons are linked → Returns to /registration/event

  5. Event participants created for linked persons

See Event Registration for full workflow details.

10. Security Considerations

10.1. User Key Context

All LinkedPerson operations require a userKey parameter for security:

userKey$ = this.sessionService.getUserKey$();

this.userKey$.subscribe(userKey => {
  if (userKey) {
    this.userKey = userKey;
  }
});

User Key Usage:

  • Create Person: POST /api/people?userKey={key}

  • Update Person: PUT /api/people/{id}?userKey={key}

  • Link Person: POST /api/linked-people/link/{id}/{type}?userKey={key}

10.2. Organisation Scoping

LinkedPerson operations are scoped to the current organisation:

linkPerson(id: number, type: string, userKey: string): Observable<ILinkedPerson> {
  return this.http.post<ILinkedPerson>(
    `${this.linkEdPersonUrl}/${id}/${type}?userKey=${userKey}&organisationId=8`,
    { observe: 'response' }
  );
}

Organisation ID:

  • Currently hardcoded to organisationId=8 in the frontend

  • Should be derived from user session context

  • Ensures person links are scoped to correct organization

See Security for multi-dimensional security details.

11. Best Practices

11.1. Search Before Create

Always search before creating:

  • Prevents duplicate person records

  • Maintains data integrity

  • Improves user experience (finds existing records)

11.2. ID Number Validation

For South African users:

  • Use ID number search as primary method

  • Validates ID format and checksum

  • Auto-extracts DOB and gender

  • Locks fields to prevent data corruption

11.3. State Management

Use LinkedPersonStateService:

  • Centralized state management

  • Reactive updates with observables

  • Clean state transitions

  • Prevents memory leaks with proper cleanup

11.4. Form Validation

Comprehensive validation:

  • Client-side validation for immediate feedback

  • Server-side validation for security

  • Custom validators for business rules

  • Clear error messages for users