Phase 2: Core Entities & Membership
Depends on: Phase 1 (Auth & Invites) New Endpoints: 8 New Files: 14
Goal
Implement organization and store membership management. After this phase, org admins can add/remove/update org members, and store admins can add/remove/update store members. This phase establishes the multi-tenant context that all subsequent phases depend on.
Endpoints
Organization-Scoped — All Org Members
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /api/v1/orgs/:org_id/members | OrgMemberController.list | List org members (paginated) |
Organization-Scoped — Org Admin (org_owner, org_admin)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /api/v1/orgs/:org_id/members | OrgMemberController.add | Add user to org |
| PUT | /api/v1/orgs/:org_id/members/:user_id | OrgMemberController.updateRole | Update member role |
| DELETE | /api/v1/orgs/:org_id/members/:user_id | OrgMemberController.remove | Remove member |
Store-Scoped — All Store Members
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /api/v1/stores/:store_id/members | StoreMemberController.list | List store members (paginated) |
Store-Scoped — Store Admin Only
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /api/v1/stores/:store_id/members | StoreMemberController.add | Add user to store |
| PUT | /api/v1/stores/:store_id/members/:user_id | StoreMemberController.updateRole | Update member role |
| DELETE | /api/v1/stores/:store_id/members/:user_id | StoreMemberController.remove | Remove member |
SQLDelight Tables & Queries
organizations table
Key queries from Organization.sq:
findById(org_id)- Lookup by UUIDfindBySlug(slug)- Lookup by URL slug
stores table
Key queries from Store.sq:
findById(store_id)- Lookup by UUIDfindByOrgId(org_id, limit, offset)- List stores in org (paginated)countByOrgId(org_id)- Pagination count
org_memberships table
Key queries from OrgMembership.sq:
findByOrgId(org_id, limit, offset)- List members (paginated)findByOrgIdWithUser(org_id, limit, offset)- List with user details (JOIN)countByOrgId(org_id)- Pagination countfindByOrgIdAndUserId(org_id, user_id)- Check membershipinsert(membership_id, org_id, user_id, role)- Add memberupdateRole(role, org_id, user_id)- Update roledelete(org_id, user_id)- Remove member
store_memberships table
Key queries from StoreMembership.sq:
findByStoreId(store_id, limit, offset)- List members (paginated)findByStoreIdWithUser(store_id, limit, offset)- List with user details (JOIN)countByStoreId(store_id)- Pagination countfindByStoreIdAndUserId(store_id, user_id)- Check membershipinsert(membership_id, store_id, user_id, role, clearances)- Add memberupdateRole(role, clearances, store_id, user_id)- Update roledelete(store_id, user_id)- Remove member
Files to Create
Controllers (2)
controller/org/OrgMemberController.java
@RequestMapping("/orgs/{orgId}/members")
GET / -> orgMemberService.list(orgId, page, size)
POST / -> orgMemberService.add(orgId, request)
PUT /{userId} -> orgMemberService.updateRole(orgId, userId, request)
DELETE /{userId} -> orgMemberService.remove(orgId, userId)
controller/store/StoreMemberController.java
@RequestMapping("/stores/{storeId}/members")
GET / -> storeMemberService.list(storeId, page, size)
POST / -> storeMemberService.add(storeId, request)
PUT /{userId} -> storeMemberService.updateRole(storeId, userId, request)
DELETE /{userId} -> storeMemberService.remove(storeId, userId)
Services (2)
service/OrgMemberService.java
- list(UUID orgId, int page, int size) -> Page<OrgMemberResponse>
- add(UUID orgId, AddOrgMemberRequest) -> OrgMemberResponse
- updateRole(UUID orgId, UUID userId, UpdateOrgMemberRoleRequest) -> OrgMemberResponse
- remove(UUID orgId, UUID userId) -> void
- Validates: user exists, not already a member (on add), can't remove last owner
service/StoreMemberService.java
- list(UUID storeId, int page, int size) -> Page<StoreMemberResponse>
- add(UUID storeId, AddStoreMemberRequest) -> StoreMemberResponse
- updateRole(UUID storeId, UUID userId, UpdateStoreMemberRoleRequest) -> StoreMemberResponse
- remove(UUID storeId, UUID userId) -> void
- Validates: user exists, user is org member first, not already a store member
Repositories (4)
repository/OrganizationRepository.java
- Wraps SQLDelight OrganizationQueries
- findById, findBySlug
repository/StoreRepository.java
- Wraps SQLDelight StoreQueries
- findById, findByOrgId, countByOrgId
repository/OrgMembershipRepository.java
- Wraps SQLDelight OrgMembershipQueries
- findByOrgIdWithUser, countByOrgId, findByOrgIdAndUserId, insert, updateRole, delete
repository/StoreMembershipRepository.java
- Wraps SQLDelight StoreMembershipQueries
- findByStoreIdWithUser, countByStoreId, findByStoreIdAndUserId, insert, updateRole, delete
Request DTOs (2)
dto/request/AddOrgMemberRequest.java
{ userId: @NotNull UUID, role: @NotBlank @Pattern("org_owner|org_admin|org_member") String }
dto/request/UpdateOrgMemberRoleRequest.java
{ role: @NotBlank @Pattern("org_owner|org_admin|org_member") String }
dto/request/AddStoreMemberRequest.java
{ userId: @NotNull UUID, role: @NotBlank @Pattern("store_admin|manager|cashier|stocker") String,
clearances: int }
dto/request/UpdateStoreMemberRoleRequest.java
{ role: @NotBlank @Pattern("store_admin|manager|cashier|stocker") String,
clearances: int }
Response DTOs (2)
dto/response/OrgMemberResponse.java
{ membershipId: UUID, orgId: UUID, userId: UUID, role: String,
email: String, firstName: String, lastName: String, active: boolean,
createdAt: Instant }
dto/response/StoreMemberResponse.java
{ membershipId: UUID, storeId: UUID, userId: UUID, role: String,
clearances: int, email: String, firstName: String, lastName: String,
active: boolean, createdAt: Instant }
Mappers (2)
mapper/OrgMemberMapper.java
- static toResponse(OrgMembershipWithUser) -> OrgMemberResponse
- static toResponseList(List<OrgMembershipWithUser>) -> List<OrgMemberResponse>
mapper/StoreMemberMapper.java
- static toResponse(StoreMembershipWithUser) -> StoreMemberResponse
- static toResponseList(List<StoreMembershipWithUser>) -> List<StoreMemberResponse>
Role Hierarchy
Org Roles
| Role | Can Manage Members | Can Create Invites | Can Manage Categories/Suppliers |
|---|---|---|---|
org_owner | Yes | Yes | Yes |
org_admin | Yes | Yes | Yes |
org_member | No (read-only list) | No | No |
Store Roles
| Role | Can Manage Members | Can Manage Products | Can Manage Inventory |
|---|---|---|---|
store_admin | Yes | Yes (delete too) | Yes |
manager | No | Yes (no delete) | Yes |
cashier | No | No (read-only) | No |
stocker | No | No (read-only) | No |
Business Rules
- A user must have an org membership before being added to any store in that org
- An org must always have at least one
org_owner- cannot remove the last owner - Removing an org member cascades: also removes all their store memberships in that org
- Store membership roles are independent of org membership roles
clearanceson store memberships is a bitmask for fine-grained permissions (future use)
Acceptance Criteria
GET /orgs/:org_id/membersreturns paginated org members with user detailsPOST /orgs/:org_id/membersadds a user to the org, 409 if already a memberPUT /orgs/:org_id/members/:user_idupdates the member's roleDELETE /orgs/:org_id/members/:user_idremoves the member (and cascading store memberships)- Cannot remove the last
org_owner(400 error) - Store member endpoints mirror org member behavior at the store level
- Adding a store member validates the user has an org membership first (400 if not)
- All list endpoints support
?page=0&size=20pagination viaApiResponse.meta - Bazel build passes
- Unit tests for OrgMemberService and StoreMemberService