Groups¶
Overview¶
A Group is the top-level organisational unit in Batanai. It represents a community of users who participate in shared financial activities together. All expense cycles (Majana and Mukando) are created within the context of a group.
Groups are managed by one or more Group Admins who can invite members, approve join requests, create cycles, and manage group settings. Regular Group Members can view the group, participate in cycles, and leave at any time.
Role Access¶
| Role | Access |
|---|---|
| SuperAdmin | Full CRUD on all groups and memberships |
| Admin | Full CRUD on all groups and memberships |
| Member | View own groups, accept/decline invites, join by code, leave group |
| Group Admin | Invite/remove members, change roles, create cycles, manage join requests, regenerate join code |
| Group Member | View group, participate in cycles, leave group |
Backend¶
Controller — GroupController¶
File: server/src/Batanai.Api/Controllers/GroupController.cs
Base route: /api/groups — all endpoints require [Authorize]
| Method | Route | Description |
|---|---|---|
| GET | / |
List all groups (admins see all; members see their own) |
| GET | /{id} |
Get group detail with members |
| POST | / |
Create new group (creator becomes Group Admin) |
| PUT | /{id} |
Update group name, description, or active status |
| DELETE | /{id} |
Delete group (cascades to all memberships) |
| GET | /{id}/members |
List all members (accepted + pending) |
| POST | /{id}/invites |
Invite a user to the group |
| POST | /{id}/invites/respond |
Accept or decline an invite |
| GET | /my-invites |
Get current user's pending invites |
| DELETE | /{id}/members/{targetUserId} |
Remove or uninvite a member |
| PUT | /{id}/members/{targetUserId}/role |
Change a member's role |
| POST | /{id}/leave |
Current user leaves the group |
| POST | /join |
Request to join a group using a join code |
| POST | /{id}/regenerate-code |
Generate a new join code (invalidates old one) |
| GET | /{id}/join-requests |
Get pending join-by-code requests |
| POST | /{id}/join-requests/{targetUserId}/respond |
Approve or decline a join request |
Model — Group¶
File: server/src/Batanai.Api/Models/Group.cs
| Field | Type | Notes |
|---|---|---|
Id |
int |
PK |
Name |
string |
Required, max 150 characters |
Description |
string? |
Optional, max 500 characters |
IsActive |
bool |
Defaults to true |
CreatedByUserId |
int |
FK → User |
CreatedAt |
DateTime |
UTC timestamp |
UpdatedAt |
DateTime |
UTC timestamp |
JoinCode |
string |
8-character unique code, required |
JoinCodeGeneratedAt |
DateTime |
When the current code was generated |
Model — GroupMember¶
File: server/src/Batanai.Api/Models/GroupMember.cs
| Field | Type | Notes |
|---|---|---|
GroupId |
int |
Composite PK — FK → Group (cascade delete) |
UserId |
int |
Composite PK — FK → User (cascade delete) |
GroupRole |
GroupRole |
GroupAdmin = 1 or GroupMember = 2 |
Status |
GroupInviteStatus |
Pending = 1, Accepted = 2, Declined = 3, JoinRequested = 4 |
InvitedByUserId |
int? |
FK → User — who sent the invite |
InvitedAt |
DateTime |
When the member was invited |
RespondedAt |
DateTime? |
When the user accepted or declined |
JoinRequestedAt |
DateTime? |
When the user submitted a join-by-code request |
ApprovedByUserId |
int? |
FK → User — who approved the join request |
ApprovedAt |
DateTime? |
When the join request was approved |
Enums¶
File: server/src/Batanai.Api/Models/BatanaiEnums.cs
public enum GroupRole
{
GroupAdmin = 1,
GroupMember = 2
}
public enum GroupInviteStatus
{
Pending = 1,
Accepted = 2,
Declined = 3,
JoinRequested = 4
}
Service — GroupService¶
File: server/src/Batanai.Api/Services/GroupService.cs
Interface: server/src/Batanai.Api/Services/IGroupService.cs
Query Methods¶
| Method | Returns | Description |
|---|---|---|
GetAllAsync(userId, userRole) |
List<GroupDto> |
SuperAdmin/Admin see all; members see groups they belong to |
GetByIdAsync(groupId, userId, userRole) |
GroupDetailDto? |
Group detail with full member list |
GetMembersAsync(groupId, userId, userRole) |
List<GroupMemberDto> |
All members (accepted + pending) |
GetPendingInvitesForUserAsync(userId) |
List<GroupInviteDto> |
Current user's pending invitations |
GetPendingJoinRequestsAsync(groupId, userId, userRole) |
List<JoinRequestDto> |
Pending join-by-code requests for admins |
Mutation Methods¶
| Method | Returns | Description |
|---|---|---|
CreateAsync(request, creatorId) |
(GroupDto?, string?) |
Creates group; creator auto-becomes Group Admin with Accepted status |
UpdateAsync(groupId, request, userId, userRole) |
(GroupDto?, string?) |
Update name, description, active status |
DeleteAsync(groupId, userId, userRole) |
string? |
Delete group (cascade deletes all memberships) |
InviteMemberAsync(groupId, request, inviterId, inviterRole) |
(GroupMemberDto?, string?) |
Creates GroupMember with Pending status; sends notification |
RespondToInviteAsync(groupId, userId, request) |
string? |
Accept or decline; if accepted, notifies existing members |
RemoveMemberAsync(groupId, targetUserId, userId, userRole) |
string? |
Remove member; cannot remove last Group Admin |
UpdateMemberRoleAsync(groupId, targetUserId, request, userId, userRole) |
string? |
Change role (Admin ↔ Member); cannot demote last Group Admin |
LeaveGroupAsync(groupId, userId) |
string? |
User leaves; cannot leave if last Group Admin |
RequestJoinByCodeAsync(joinCode, userId) |
(JoinByCodeResponse?, string?) |
Creates member with JoinRequested status; notifies all Group Admins |
RespondToJoinRequestAsync(groupId, requestingUserId, approve, respondingUserId, userRole) |
string? |
Approve → Accepted + notify; decline → Declined + notify |
RegenerateJoinCodeAsync(groupId, userId, userRole) |
(string?, string?, string?) |
Generates new 8-char code; old code becomes invalid |
IsGroupAdminOfGroupAsync(groupId, userId) |
bool |
Check if user is admin of specific group |
Join Code Generation¶
Join codes are 8 characters drawn from ABCDEFGHJKMNPQRSTUVWXYZ23456789 (excludes ambiguous characters: 0, O, 1, I, L). Generated using RandomNumberGenerator.Fill() for cryptographic security, with up to 10 retry attempts to ensure uniqueness.
Last-Admin Protection¶
The service prevents the group from losing all administrators:
- Cannot remove the last accepted Group Admin
- Cannot demote the last accepted Group Admin to Member
- Cannot leave the group if you are the last Group Admin
DTOs¶
File: server/src/Batanai.Api/DTOs/Group/GroupDtos.cs
Request DTOs¶
| DTO | Fields |
|---|---|
CreateGroupRequest |
Name, Description? |
UpdateGroupRequest |
Name, Description?, IsActive |
InviteGroupMemberRequest |
UserId, GroupRole |
RespondToInviteRequest |
Accept (bool) |
UpdateGroupMemberRoleRequest |
GroupRole |
JoinByCodeRequest |
JoinCode |
RespondToJoinRequestRequest |
Approve (bool) |
Response DTOs¶
| DTO | Fields |
|---|---|
GroupDto |
Id, Name, Description?, IsActive, MemberCount, CreatedAt, CanManage, JoinCode?, JoinUrl? |
GroupDetailDto |
Extends GroupDto + Members: IReadOnlyList<GroupMemberDto> |
GroupMemberDto |
UserId, FirstName, LastName, Email, GroupRole, Status, InvitedAt, RespondedAt? |
GroupInviteDto |
GroupId, GroupName, InvitedByUserId, InvitedByName, GroupRole, InvitedAt, Status |
JoinByCodeResponse |
GroupId, GroupName, Message |
JoinRequestDto |
UserId, FirstName, LastName, Email, RequestedAt |
Database Configuration¶
File: server/src/Batanai.Api/Data/ApplicationDbContext.cs
public DbSet<Group> Groups { get; set; } = null!;
public DbSet<GroupMember> GroupMembers { get; set; } = null!;
Group entity — Table "Groups":
- JoinCode: Required, max 8 chars, unique index
- Name: Required, max 150 chars
- CreatedByUserId: FK → User with Restrict delete
GroupMember entity — Table "GroupMembers":
- Composite key: (GroupId, UserId)
- GroupId → Group with Cascade delete
- UserId → User with Cascade delete
- InvitedByUserId → User with Restrict delete
- ApprovedByUserId → User with Restrict delete
Frontend¶
Module¶
File: client/src/app/features/groups/groups.module.ts
Declares 5 components:
1. GroupListComponent
2. GroupDetailComponent
3. CreateGroupDialogComponent
4. GroupMembersDialogComponent
5. JoinGroupDialogComponent
Route: /groups (lazy-loaded)
Models¶
File: client/src/app/shared/models/group.model.ts
Mirrors server DTOs as TypeScript interfaces:
interface GroupDto {
id: number; name: string; description?: string; isActive: boolean;
memberCount: number; createdAt: string; canManage: boolean;
joinCode?: string; joinUrl?: string;
}
interface GroupDetailDto extends GroupDto {
members: GroupMemberDto[];
}
interface GroupMemberDto {
userId: number; firstName: string; lastName: string; email: string;
groupRole: string; status: string; invitedAt: string; respondedAt?: string;
}
interface GroupInviteDto {
groupId: number; groupName: string; invitedByUserId: number;
invitedByName: string; groupRole: string; invitedAt: string; status: string;
}
interface JoinByCodeResponse { groupId: number; groupName: string; message: string; }
interface JoinRequestDto { userId: number; firstName: string; lastName: string; email: string; requestedAt: string; }
Service¶
File: client/src/app/core/services/group.service.ts
| Method | HTTP | Endpoint |
|---|---|---|
getGroups() |
GET | /api/groups |
getGroup(id) |
GET | /api/groups/{id} |
createGroup(req) |
POST | /api/groups |
updateGroup(id, req) |
PUT | /api/groups/{id} |
deleteGroup(id) |
DELETE | /api/groups/{id} |
getGroupMembers(groupId) |
GET | /api/groups/{groupId}/members |
inviteMember(groupId, req) |
POST | /api/groups/{groupId}/invites |
removeMember(groupId, userId) |
DELETE | /api/groups/{groupId}/members/{userId} |
updateMemberRole(groupId, userId, req) |
PUT | /api/groups/{groupId}/members/{userId}/role |
getMyInvites() |
GET | /api/groups/my-invites |
respondToInvite(groupId, req) |
POST | /api/groups/{groupId}/invites/respond |
leaveGroup(groupId) |
POST | /api/groups/{groupId}/leave |
joinByCode(req) |
POST | /api/groups/join |
regenerateJoinCode(groupId) |
POST | /api/groups/{groupId}/regenerate-code |
getJoinRequests(groupId) |
GET | /api/groups/{groupId}/join-requests |
respondToJoinRequest(groupId, userId, req) |
POST | /api/groups/{groupId}/join-requests/{userId}/respond |
Components¶
GroupListComponent¶
File: client/src/app/features/groups/group-list/group-list.component.ts
Route: /groups
- Displays groups in a table with columns: Name, Member Count, Status, Created Date, Actions
- Auto-join: If
?join=CODEis in the URL (e.g. from a QR scan), automatically opens the join dialog pre-filled - Action buttons: Create Group, Join Group, Manage Members, Edit, Delete, View Details
- Empty state: "No groups yet. Create one to get started."
GroupDetailComponent¶
File: client/src/app/features/groups/group-detail/group-detail.component.ts
Route: /groups/{id}
- Group info card with name, description, and status badge
- Join Code Section (Group Admins only):
- Displays the 8-character join code
- QR code rendered via
angularx-qrcode - Copy to clipboard button
- Share button (uses
navigator.shareAPI where available) - Regenerate button with confirmation warning
- Hint text explaining admin approval is required
- Cycles Section: Table of cycles within the group with navigation to cycle detail
- Actions: Manage Members, Edit Group, Leave Group (members), New Cycle (admins)
CreateGroupDialogComponent¶
File: client/src/app/features/groups/create-group-dialog/create-group-dialog.component.ts
Modal dialog for creating or editing a group: - Name field: Required, max 150 characters - Description field: Optional, max 500 characters - IsActive toggle (edit mode only) - Pre-populates fields when editing an existing group
GroupMembersDialogComponent¶
File: client/src/app/features/groups/group-members-dialog/group-members-dialog.component.ts
Modal dialog with different views based on role:
Admin view: - Invite Section: User search dropdown + role selector (Member / Admin) + Send Invite button - Accepted Members Table: Name, Email, Role (inline editable), Status badge, Remove button - Pending Invites Table: Name, Email, Role, Status badge, Cancel button - Join Requests Table: Name, Email, Requested date, Approve / Decline buttons
Member view (read-only): - Accepted members table without edit/remove actions - Invite and join request sections hidden
JoinGroupDialogComponent¶
File: client/src/app/features/groups/join-group-dialog/join-group-dialog.component.ts
Modal dialog for joining a group by code:
- Text input for 8-character code (uppercase enforced)
- Auto-populates if ?join=CODE was in the URL
- Shows success or error messages
- Closes automatically on success
Notifications¶
| Type | ID | Trigger | Recipients | Delivery |
|---|---|---|---|---|
GroupInviteReceived |
6 | Admin invites user | Invited user | In-app |
MemberJoinedGroup |
16 | User accepts invite or join request approved | Existing accepted members | Push |
MemberLeftGroup |
15 | User leaves or is removed | Removed user + remaining members | Push |
JoinRequestReceived |
33 | User submits join code | All Group Admins | Push |
JoinRequestApproved |
34 | Admin approves join request | Requesting user | Push |
JoinRequestDeclined |
35 | Admin declines join request | Requesting user | Push |
Workflows¶
Create Group¶
- User clicks New Group on the groups list
- Modal opens with name and description fields
- User submits →
POST /api/groups - Server creates
Groupwith a generated join code - Creator is automatically added as
GroupAdminwithAcceptedstatus - Modal closes, groups list reloads
Invite Member¶
- Group Admin opens Manage Members dialog
- Searches for user by name or email, selects role
- Clicks Send Invite →
POST /api/groups/{id}/invites - Server creates
GroupMemberwithStatus = Pending - Invited user receives
GroupInviteReceivedin-app notification - Members table updates with new pending entry
Accept / Decline Invite¶
- User receives
GroupInviteReceivednotification - Navigates to pending invites (via notification or
/groups/my-invites) - Clicks Accept or Decline →
POST /api/groups/{id}/invites/respond - If accepted: status →
Accepted; existing members receiveMemberJoinedGrouppush notification - If declined: status →
Declined
Join by Code¶
- Group Admin shares the 8-character join code or QR link
- User enters code in Join Group dialog or scans QR →
POST /api/groups/join - Server creates
GroupMemberwithStatus = JoinRequested - All Group Admins receive
JoinRequestReceivedpush notification - Admin approves or declines →
POST /api/groups/{id}/join-requests/{userId}/respond - If approved: status →
Accepted; user receivesJoinRequestApproved; existing members receiveMemberJoinedGroup - If declined: status →
Declined; user receivesJoinRequestDeclined
Leave Group¶
- Member clicks Leave Group on group detail
- Confirmation dialog appears
- User confirms →
POST /api/groups/{id}/leave - Server removes
GroupMember(blocked if last Group Admin) - Remaining members receive
MemberLeftGrouppush notification - User is redirected to
/groups
Regenerate Join Code¶
- Group Admin clicks Regenerate on the join code section
- Confirmation warning: old code becomes invalid immediately
- User confirms →
POST /api/groups/{id}/regenerate-code - Server generates new 8-character code
- QR code and displayed code update in the UI