PastCare custom roles system — role CRUD, permission picker, display names, privilege escalation guards, and cache invalidation. Use when writing or reviewing code related to custom roles, role assignment, or permission management.
Custom roles let a church create roles beyond the 7 built-in roles. They are:
| Class | Location | Responsibility |
|---|---|---|
CustomRole | models/CustomRole.java | Entity — name, displayName, description, permissions (Set<Permission>), church, active |
CustomRoleService |
services/CustomRoleService.java| CRUD + permission validation + in-memory cache |
CustomRoleController | controllers/CustomRoleController.java | REST endpoints under /api/roles |
CustomRoleRepository | repositories/CustomRoleRepository.java | JPA queries |
| File | Location | Responsibility |
|---|---|---|
custom-role.ts | interfaces/custom-role.ts | All TypeScript interfaces + PERMISSION_DISPLAY_NAMES + getPermissionCategory() + getPermissionDisplayName() |
CustomRoleService | services/custom-role.service.ts | HTTP calls + BehaviorSubject cache for roles lists |
custom-roles-page | custom-roles-page/ | Management UI — list, create, edit, delete roles |
permission-picker | components/permission-picker/ | Reusable permission selection component |
All under /api/roles, require authentication.
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /api/roles | USER_MANAGE_ROLES or USER_MANAGE | All roles (built-in + custom) as RoleSummary[] |
| GET | /api/roles/custom | USER_MANAGE_ROLES or USER_MANAGE | Custom roles for current church |
| GET | /api/roles/custom/me | (any authenticated user) | Current user's own custom role + permissions |
| GET | /api/roles/custom/{id} | USER_MANAGE_ROLES | Custom role detail with permissions |
| POST | /api/roles/custom | USER_MANAGE_ROLES | Create custom role |
| PUT | /api/roles/custom/{id} | USER_MANAGE_ROLES | Update display name / description / active |
| PUT | /api/roles/custom/{id}/permissions | USER_MANAGE_ROLES | Replace permission set |
| DELETE | /api/roles/custom/{id} | USER_MANAGE_ROLES | Delete role (users fall back to MEMBER) |
| GET | /api/roles/permissions | USER_MANAGE_ROLES | Available permissions grouped by category |
| GET | /api/roles/builtin | USER_VIEW | All built-in roles (excludes SUPERADMIN + FELLOWSHIP_HEAD) |
| GET | /api/roles/builtin/{role} | USER_VIEW | Single built-in role permissions |
private static final Set<Permission> MAX_ALLOWED_PERMISSIONS = Role.ADMIN.getPermissions();
Custom roles CANNOT include PLATFORM permissions or permissions not held by ADMIN.
private static final Set<Permission> BASE_MEMBER_PERMISSIONS = Set.of(
Permission.MEMBER_VIEW_OWN,
Permission.MEMBER_EDIT_OWN,
Permission.DONATION_VIEW_OWN,
Permission.PLEDGE_VIEW_OWN
);
These are always guaranteed regardless of what the admin selects.
Only shown and assignable if the current church is a denomination headquarters.
Checked via: denominationRepository.findByHeadquartersChurchId(churchId).isPresent()
CustomRoleService.validatePermissions() throws PrivilegeEscalationException if:
PLATFORMDENOMINATION_HQ and church is not the denomination HQMAX_ALLOWED_PERMISSIONSAll display names live in PERMISSION_DISPLAY_NAMES in interfaces/custom-role.ts.
{Verb} {Scope} {Resource} e.g. View Fellowship AttendanceManage Households (Create, Edit, Delete)View All Members| Permission | Display Name | Notes |
|---|---|---|
MEMBER_VIEW_ALL | View All Members | "All" for all-scoped |
MEMBER_VIEW_OWN | View Own Profile | "Own" for self-scoped |
MEMBER_VIEW_FELLOWSHIP | View Fellowship Members | "Fellowship" for fellowship-scoped |
ATTENDANCE_VIEW_FELLOWSHIP | View Fellowship Attendance | No "Only" suffix |
ATTENDANCE_MARK_FELLOWSHIP | Mark Attendance (Fellowship Only) | "Only" inside parens for clarity when verb ambiguous |
SMS_SEND_FELLOWSHIP | Send SMS to Fellowship | No "Own" or "Only" suffix |
EVENT_VIEW_PUBLIC | View Public Events | No "Only" suffix |
OUTREACH_VIEW_ASSIGNED | View Assigned Outreach | Use "Assigned" not "My" |
PERMISSION_DISPLAY_NAMES in interfaces/custom-role.tsgetPermissionCategory() handles the permission's prefixPermission.java (backend enum) with JavadocRole.java (at minimum ADMIN role)permission.enum.ts with exact same nameDefined in getPermissionCategory() in interfaces/custom-role.ts:
| Category | Prefixes |
|---|---|
| Member Management | MEMBER_ |
| Household | HOUSEHOLD_ |
| Fellowship | FELLOWSHIP_ |
| Financial | DONATION_, PLEDGE_, CAMPAIGN_, RECEIPT_, BUDGET_, EXPENSE_, FUND_, DUES_, WELFARE_ |
| Events | EVENT_ |
| Attendance | ATTENDANCE_ |
| Visitors | VISITOR_ |
| Guests | GUEST_ |
| Pastoral Care | CARE_NEED_, VISIT_, PRAYER_REQUEST_, CRISIS_, COUNSELING_, OUTREACH_ |
| Notifications | NOTIFICATION_ |
| Communication | SMS_, EMAIL_, BULK_MESSAGE_ |
| Reports | REPORT_ |
| Dashboard | DASHBOARD_ |
| Goals | GOAL_ |
| Administration | USER_, CHURCH_SETTINGS_, SUBSCRIPTION_, STORAGE_, BILLING_VIEW, REFERRAL_ |
| Denomination | DENOMINATION_HQ_ |
| Platform (SUPERADMIN) | PLATFORM_, ALL_CHURCHES_, BILLING_, SYSTEM_, SUPERADMIN_, DENOMINATION_ |
Names are normalized on save: toUpperCase().replace(" ", "_").replace("-", "_")
Custom role names CANNOT conflict with built-in role names (ADMIN, PASTOR, TREASURER, etc.).
Uniqueness is enforced per church: existsByChurch_IdAndNameIgnoreCase(churchId, normalizedName).
CustomRoleService uses a ConcurrentHashMap<String, Set<Permission>> keyed by "churchId:roleId".
getPermissionsForRole()Collections.emptySet() (not cached)customRoleService.clearCache() in tests that modify rolesWhen a custom role is deleted:
customRole set to nullrole is set to Role.MEMBERUsed for dropdowns (user assignment, user creation):
interface RoleSummary {
id: string; // "ADMIN" for built-in, "custom:123" for custom
name: string; // Normalized name e.g. "WORSHIP_TEAM"
displayName: string; // Human-friendly e.g. "Worship Team"
description?: string;
isBuiltIn: boolean;
isEditable: boolean;
permissionCount: number;
}
Identify custom roles by checking !role.isBuiltIn or role.id.startsWith('custom:').
invalidateCache() after any permission or active-status changegetAvailablePermissions()DuplicateRoleExceptionrole.isActive in UI