Skip to main content

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

MethodPathHandlerDescription
GET/api/v1/orgs/:org_id/membersOrgMemberController.listList org members (paginated)

Organization-Scoped — Org Admin (org_owner, org_admin)

MethodPathHandlerDescription
POST/api/v1/orgs/:org_id/membersOrgMemberController.addAdd user to org
PUT/api/v1/orgs/:org_id/members/:user_idOrgMemberController.updateRoleUpdate member role
DELETE/api/v1/orgs/:org_id/members/:user_idOrgMemberController.removeRemove member

Store-Scoped — All Store Members

MethodPathHandlerDescription
GET/api/v1/stores/:store_id/membersStoreMemberController.listList store members (paginated)

Store-Scoped — Store Admin Only

MethodPathHandlerDescription
POST/api/v1/stores/:store_id/membersStoreMemberController.addAdd user to store
PUT/api/v1/stores/:store_id/members/:user_idStoreMemberController.updateRoleUpdate member role
DELETE/api/v1/stores/:store_id/members/:user_idStoreMemberController.removeRemove member

SQLDelight Tables & Queries

organizations table

Key queries from Organization.sq:

  • findById(org_id) - Lookup by UUID
  • findBySlug(slug) - Lookup by URL slug

stores table

Key queries from Store.sq:

  • findById(store_id) - Lookup by UUID
  • findByOrgId(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 count
  • findByOrgIdAndUserId(org_id, user_id) - Check membership
  • insert(membership_id, org_id, user_id, role) - Add member
  • updateRole(role, org_id, user_id) - Update role
  • delete(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 count
  • findByStoreIdAndUserId(store_id, user_id) - Check membership
  • insert(membership_id, store_id, user_id, role, clearances) - Add member
  • updateRole(role, clearances, store_id, user_id) - Update role
  • delete(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

RoleCan Manage MembersCan Create InvitesCan Manage Categories/Suppliers
org_ownerYesYesYes
org_adminYesYesYes
org_memberNo (read-only list)NoNo

Store Roles

RoleCan Manage MembersCan Manage ProductsCan Manage Inventory
store_adminYesYes (delete too)Yes
managerNoYes (no delete)Yes
cashierNoNo (read-only)No
stockerNoNo (read-only)No

Business Rules

  1. A user must have an org membership before being added to any store in that org
  2. An org must always have at least one org_owner - cannot remove the last owner
  3. Removing an org member cascades: also removes all their store memberships in that org
  4. Store membership roles are independent of org membership roles
  5. clearances on store memberships is a bitmask for fine-grained permissions (future use)

Acceptance Criteria

  1. GET /orgs/:org_id/members returns paginated org members with user details
  2. POST /orgs/:org_id/members adds a user to the org, 409 if already a member
  3. PUT /orgs/:org_id/members/:user_id updates the member's role
  4. DELETE /orgs/:org_id/members/:user_id removes the member (and cascading store memberships)
  5. Cannot remove the last org_owner (400 error)
  6. Store member endpoints mirror org member behavior at the store level
  7. Adding a store member validates the user has an org membership first (400 if not)
  8. All list endpoints support ?page=0&size=20 pagination via ApiResponse.meta
  9. Bazel build passes
  10. Unit tests for OrgMemberService and StoreMemberService