Phase 7: Engagement
Depends on: Phase 2 (Core Entities & Membership), Phase 3 (Product Catalog) New Endpoints: 31 New Files: 31
Goal
Implement customer engagement features: customer management (loyalty points), promotions (BOGO, discounts), notifications (in-app alerts), stock transfers (between stores), and activity logging (audit trail). After this phase, the merchant-api covers all org-level operational features.
Endpoints
Customers — All Org Members (Read)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /orgs/:org_id/customers | OrgCustomerController.list | List customers (paginated) |
| GET | /orgs/:org_id/customers/:customer_id | OrgCustomerController.get | Get customer |
Customers — All Org Members (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /orgs/:org_id/customers | OrgCustomerController.create | Create customer |
| PUT | /orgs/:org_id/customers/:customer_id | OrgCustomerController.update | Update customer |
| POST | /orgs/:org_id/customers/:customer_id/points | OrgCustomerController.adjustPoints | Add/subtract loyalty points |
Customers — Org Admin (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| DELETE | /orgs/:org_id/customers/:customer_id | OrgCustomerController.delete | Delete customer |
Promotions — All Org Members (Read)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /orgs/:org_id/promotions | OrgPromotionController.list | List promotions (paginated) |
| GET | /orgs/:org_id/promotions/active | OrgPromotionController.listActive | Active promotions |
| GET | /orgs/:org_id/promotions/:promotion_id | OrgPromotionController.get | Get promotion |
Promotions — Org Admin (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /orgs/:org_id/promotions | OrgPromotionController.create | Create promotion |
| PUT | /orgs/:org_id/promotions/:promotion_id | OrgPromotionController.update | Update promotion |
| DELETE | /orgs/:org_id/promotions/:promotion_id | OrgPromotionController.delete | Delete promotion |
| POST | /orgs/:org_id/promotions/:promotion_id/toggle | OrgPromotionController.toggleActive | Toggle active |
Notifications — All Org Members
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /orgs/:org_id/notifications | OrgNotificationController.list | List notifications (paginated) |
| GET | /orgs/:org_id/notifications/unread-count | OrgNotificationController.countUnread | Count unread |
| GET | /orgs/:org_id/notifications/:notification_id | OrgNotificationController.get | Get notification |
| POST | /orgs/:org_id/notifications/:notification_id/read | OrgNotificationController.markRead | Mark read |
| POST | /orgs/:org_id/notifications/read-all | OrgNotificationController.markAllRead | Mark all read |
Notifications — Org Admin (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /orgs/:org_id/notifications | OrgNotificationController.create | Create notification |
Stock Transfers — All Org Members (Read)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /orgs/:org_id/stock-transfers | OrgStockTransferController.list | List transfers (paginated) |
| GET | /orgs/:org_id/stock-transfers/:transfer_id | OrgStockTransferController.get | Get transfer with items |
Stock Transfers — Org Admin (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /orgs/:org_id/stock-transfers | OrgStockTransferController.create | Create transfer |
| POST | /orgs/:org_id/stock-transfers/:transfer_id/ship | OrgStockTransferController.ship | Mark as shipped |
| POST | /orgs/:org_id/stock-transfers/:transfer_id/receive | OrgStockTransferController.receive | Receive transfer |
| POST | /orgs/:org_id/stock-transfers/:transfer_id/cancel | OrgStockTransferController.cancel | Cancel transfer |
Activity Log — Org Admin
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /orgs/:org_id/activity-log | OrgActivityLogController.list | List org activity logs |
Activity Log — Store Admin
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /stores/:store_id/activity-log | StoreActivityLogController.list | List store activity logs |
| GET | /stores/:store_id/activity-log/actions | StoreActivityLogController.getActions | Get distinct actions |
| GET | /stores/:store_id/activity-log/entity-types | StoreActivityLogController.getEntityTypes | Get entity types |
| GET | /stores/:store_id/activity-log/user/:user_id | StoreActivityLogController.getUserSummary | User activity summary |
| GET | /stores/:store_id/activity-log/:id | StoreActivityLogController.get | Get single log entry |
SQLDelight Tables & Queries
customers
findByOrgId(org_id, limit, offset)- PaginatedfindById(customer_id)- SinglesearchByName(org_id, query, limit, offset)- ILIKE searchcountByOrgId(org_id)- Countinsert(...),update(...),delete(customer_id)adjustPoints(points_delta, customer_id)- Atomic points adjustment
promotions
findByOrgId(org_id, limit, offset)- PaginatedfindActiveByOrgId(org_id)- Active promotions (within date range)findById(promotion_id)- SinglecountByOrgId(org_id)- Countinsert(...),update(...),delete(promotion_id)toggleActive(active, promotion_id)- Toggle
promotion_products / promotion_stores (junction tables)
findByPromotionId(promotion_id)- List linked products/storesinsert(promotion_id, product_id/store_id)- LinkdeleteByPromotionId(promotion_id)- Unlink all (for update)
notifications
findByOrgId(org_id, limit, offset)- PaginatedfindById(notification_id)- SinglecountUnreadByOrgId(org_id)- Unread countinsert(...)- CreatemarkRead(notification_id)- Setread = truemarkAllRead(org_id)- Set allread = true
stock_transfers / stock_transfer_items
findByOrgId(org_id, limit, offset)- PaginatedfindById(transfer_id)- Single with itemscountByOrgId(org_id)- Countinsert(...)- Create transferupdateStatus(status, transfer_id)- Ship/receive/cancel- Items:
findByTransferId(transfer_id),insert(...),updateReceivedQty(...)
activity_logs
findByOrgId(org_id, limit, offset)- Org-level activityfindByStoreId(store_id, limit, offset)- Store-level activityfindById(log_id)- SinglefindDistinctActions(store_id)- Distinct action typesfindDistinctEntityTypes(store_id)- Distinct entity typesfindByUserIdAndStoreId(user_id, store_id)- User's activity at a storecountByOrgId(org_id),countByStoreId(store_id)- Countsinsert(...)- Create log entry
Files to Create
Controllers (5)
controller/org/OrgCustomerController.java (6 endpoints)
controller/org/OrgPromotionController.java (7 endpoints)
controller/org/OrgNotificationController.java (6 endpoints)
controller/org/OrgStockTransferController.java (6 endpoints)
controller/org/OrgActivityLogController.java (1 endpoint)
controller/store/StoreActivityLogController.java (5 endpoints)
Services (5)
service/CustomerService.java
- list, get, create, update, delete, adjustPoints, search
- adjustPoints: atomic increment/decrement of loyalty points
service/PromotionService.java
- list, listActive, get, create, update, delete, toggleActive
- create/update: manages promotion_products and promotion_stores junctions
- Promotion types: BOGO (buy_quantity + get_quantity), PERCENT_OFF, FIXED_OFF
service/NotificationService.java
- list, get, countUnread, create, markRead, markAllRead
- Notifications have optional metadata (JSON) and optional store_id scope
service/StockTransferService.java
- list, get, create, ship, receive, cancel
- Status flow: "pending" -> "shipped" -> "received" (or "cancelled" at any point)
- receive: updates inventory levels at both source and destination stores
- receive: validates received_qty for each item
service/ActivityLogService.java
- listByOrg, listByStore, get, getActions, getEntityTypes, getUserSummary
- Activity logs are CREATED by other services (not directly by users)
- This service only provides read access
Repositories (5)
repository/CustomerRepository.java
repository/PromotionRepository.java
repository/NotificationRepository.java
repository/StockTransferRepository.java
repository/ActivityLogRepository.java
Request DTOs (6)
dto/request/CreateCustomerRequest.java
{ firstName: String, lastName: String, email: @Email String,
phone: String, dob: LocalDate }
dto/request/UpdateCustomerRequest.java
(same as create)
dto/request/AdjustPointsRequest.java
{ points: @NotNull int } -- positive to add, negative to subtract
dto/request/CreatePromotionRequest.java
{ name: @NotBlank String, description: String,
promotionType: @NotBlank String, buyQuantity: int, getQuantity: int,
discountValue: BigDecimal, appliesToAllStores: boolean,
startDate: @NotNull Instant, endDate: @NotNull Instant,
maxUsesPerTransaction: Integer,
productIds: List<UUID>, storeIds: List<UUID> }
dto/request/UpdatePromotionRequest.java
(same as create)
dto/request/CreateNotificationRequest.java
{ storeId: UUID, type: @NotBlank String, title: @NotBlank String,
message: @NotBlank String, metadata: Map<String, Object> }
dto/request/CreateStockTransferRequest.java
{ fromStoreId: @NotNull UUID, toStoreId: @NotNull UUID, notes: String,
items: @NotEmpty List<StockTransferItemRequest> }
(inner: { productId: @NotNull UUID, quantity: @NotNull @Min(1) int })
dto/request/ReceiveStockTransferRequest.java
{ items: @NotEmpty List<ReceiveItemRequest> }
(inner: { itemId: @NotNull UUID, receivedQty: @NotNull @Min(0) int })
Response DTOs (5)
dto/response/CustomerResponse.java
{ customerId, orgId, firstName, lastName, email, phone, dob,
points, createdAt, updatedAt }
dto/response/PromotionResponse.java
{ promotionId, orgId, name, description, promotionType,
buyQuantity, getQuantity, discountValue, appliesToAllStores,
startDate, endDate, active, maxUsesPerTransaction,
productIds, storeIds, createdAt, updatedAt }
dto/response/NotificationResponse.java
{ notificationId, orgId, storeId, type, title, message,
metadata, read, createdAt }
dto/response/StockTransferResponse.java
{ transferId, orgId, fromStoreId, toStoreId, status,
initiatedBy, receivedBy, notes, items, createdAt, completedAt }
dto/response/ActivityLogResponse.java
{ logId, actorType, actorId, orgId, storeId, action,
entityType, entityId, details, ipAddress, createdAt }
Mappers (5)
mapper/CustomerMapper.java
mapper/PromotionMapper.java
mapper/NotificationMapper.java
mapper/StockTransferMapper.java
mapper/ActivityLogMapper.java
Business Rules
Customers
- Customers belong to an org (not a specific store)
- Points can be negative (debt/penalty) — no floor enforcement
- Customer search uses ILIKE on first_name + last_name
Promotions
- Promotion types:
bogo(buy X get Y),percent_off,fixed_off applies_to_all_stores = truemeans no store_ids needed- Product IDs and store IDs are stored in junction tables
- Active promotions are filtered by date range AND active flag
Notifications
- Notifications are org-scoped, optionally store-scoped
metadatais freeform JSON (e.g.,{"order_id": "...", "severity": "high"})- Mark-all-read affects only the requesting org's notifications
Stock Transfers
- Both stores must belong to the same org
- Status flow:
pending->shipped->received(orcancelled) - On receive, inventory is decremented at source and incremented at destination
received_qtymay differ from requestedquantity(partial receipt)- Creating a transfer does NOT immediately affect inventory — only receive does
Activity Logs
- Logs are write-once (no update/delete endpoints)
- Created automatically by service methods (e.g., "user_added", "product_updated")
detailsis JSONB containing contextual dataactor_typedistinguishes user actions from system actions
Acceptance Criteria
- Customer CRUD works with loyalty points adjustment
- Customer search returns results matching name
- Promotion CRUD works with product/store linking
- Promotion toggle and active filtering works
- Notification CRUD works with unread count and mark-read
- Stock transfer lifecycle works: create -> ship -> receive (with inventory updates)
- Partial receipt (received_qty != quantity) is handled
- Activity log read endpoints work with filtering by store, user, action type
- All list endpoints support pagination
- Bazel build passes
- Unit tests for CustomerService, PromotionService, NotificationService, StockTransferService