Skip to main content

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)

MethodPathHandlerDescription
GET/orgs/:org_id/customersOrgCustomerController.listList customers (paginated)
GET/orgs/:org_id/customers/:customer_idOrgCustomerController.getGet customer

Customers — All Org Members (Write)

MethodPathHandlerDescription
POST/orgs/:org_id/customersOrgCustomerController.createCreate customer
PUT/orgs/:org_id/customers/:customer_idOrgCustomerController.updateUpdate customer
POST/orgs/:org_id/customers/:customer_id/pointsOrgCustomerController.adjustPointsAdd/subtract loyalty points

Customers — Org Admin (Write)

MethodPathHandlerDescription
DELETE/orgs/:org_id/customers/:customer_idOrgCustomerController.deleteDelete customer

Promotions — All Org Members (Read)

MethodPathHandlerDescription
GET/orgs/:org_id/promotionsOrgPromotionController.listList promotions (paginated)
GET/orgs/:org_id/promotions/activeOrgPromotionController.listActiveActive promotions
GET/orgs/:org_id/promotions/:promotion_idOrgPromotionController.getGet promotion

Promotions — Org Admin (Write)

MethodPathHandlerDescription
POST/orgs/:org_id/promotionsOrgPromotionController.createCreate promotion
PUT/orgs/:org_id/promotions/:promotion_idOrgPromotionController.updateUpdate promotion
DELETE/orgs/:org_id/promotions/:promotion_idOrgPromotionController.deleteDelete promotion
POST/orgs/:org_id/promotions/:promotion_id/toggleOrgPromotionController.toggleActiveToggle active

Notifications — All Org Members

MethodPathHandlerDescription
GET/orgs/:org_id/notificationsOrgNotificationController.listList notifications (paginated)
GET/orgs/:org_id/notifications/unread-countOrgNotificationController.countUnreadCount unread
GET/orgs/:org_id/notifications/:notification_idOrgNotificationController.getGet notification
POST/orgs/:org_id/notifications/:notification_id/readOrgNotificationController.markReadMark read
POST/orgs/:org_id/notifications/read-allOrgNotificationController.markAllReadMark all read

Notifications — Org Admin (Write)

MethodPathHandlerDescription
POST/orgs/:org_id/notificationsOrgNotificationController.createCreate notification

Stock Transfers — All Org Members (Read)

MethodPathHandlerDescription
GET/orgs/:org_id/stock-transfersOrgStockTransferController.listList transfers (paginated)
GET/orgs/:org_id/stock-transfers/:transfer_idOrgStockTransferController.getGet transfer with items

Stock Transfers — Org Admin (Write)

MethodPathHandlerDescription
POST/orgs/:org_id/stock-transfersOrgStockTransferController.createCreate transfer
POST/orgs/:org_id/stock-transfers/:transfer_id/shipOrgStockTransferController.shipMark as shipped
POST/orgs/:org_id/stock-transfers/:transfer_id/receiveOrgStockTransferController.receiveReceive transfer
POST/orgs/:org_id/stock-transfers/:transfer_id/cancelOrgStockTransferController.cancelCancel transfer

Activity Log — Org Admin

MethodPathHandlerDescription
GET/orgs/:org_id/activity-logOrgActivityLogController.listList org activity logs

Activity Log — Store Admin

MethodPathHandlerDescription
GET/stores/:store_id/activity-logStoreActivityLogController.listList store activity logs
GET/stores/:store_id/activity-log/actionsStoreActivityLogController.getActionsGet distinct actions
GET/stores/:store_id/activity-log/entity-typesStoreActivityLogController.getEntityTypesGet entity types
GET/stores/:store_id/activity-log/user/:user_idStoreActivityLogController.getUserSummaryUser activity summary
GET/stores/:store_id/activity-log/:idStoreActivityLogController.getGet single log entry

SQLDelight Tables & Queries

customers

  • findByOrgId(org_id, limit, offset) - Paginated
  • findById(customer_id) - Single
  • searchByName(org_id, query, limit, offset) - ILIKE search
  • countByOrgId(org_id) - Count
  • insert(...), update(...), delete(customer_id)
  • adjustPoints(points_delta, customer_id) - Atomic points adjustment

promotions

  • findByOrgId(org_id, limit, offset) - Paginated
  • findActiveByOrgId(org_id) - Active promotions (within date range)
  • findById(promotion_id) - Single
  • countByOrgId(org_id) - Count
  • insert(...), update(...), delete(promotion_id)
  • toggleActive(active, promotion_id) - Toggle

promotion_products / promotion_stores (junction tables)

  • findByPromotionId(promotion_id) - List linked products/stores
  • insert(promotion_id, product_id/store_id) - Link
  • deleteByPromotionId(promotion_id) - Unlink all (for update)

notifications

  • findByOrgId(org_id, limit, offset) - Paginated
  • findById(notification_id) - Single
  • countUnreadByOrgId(org_id) - Unread count
  • insert(...) - Create
  • markRead(notification_id) - Set read = true
  • markAllRead(org_id) - Set all read = true

stock_transfers / stock_transfer_items

  • findByOrgId(org_id, limit, offset) - Paginated
  • findById(transfer_id) - Single with items
  • countByOrgId(org_id) - Count
  • insert(...) - Create transfer
  • updateStatus(status, transfer_id) - Ship/receive/cancel
  • Items: findByTransferId(transfer_id), insert(...), updateReceivedQty(...)

activity_logs

  • findByOrgId(org_id, limit, offset) - Org-level activity
  • findByStoreId(store_id, limit, offset) - Store-level activity
  • findById(log_id) - Single
  • findDistinctActions(store_id) - Distinct action types
  • findDistinctEntityTypes(store_id) - Distinct entity types
  • findByUserIdAndStoreId(user_id, store_id) - User's activity at a store
  • countByOrgId(org_id), countByStoreId(store_id) - Counts
  • insert(...) - 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

  1. Customers belong to an org (not a specific store)
  2. Points can be negative (debt/penalty) — no floor enforcement
  3. Customer search uses ILIKE on first_name + last_name

Promotions

  1. Promotion types: bogo (buy X get Y), percent_off, fixed_off
  2. applies_to_all_stores = true means no store_ids needed
  3. Product IDs and store IDs are stored in junction tables
  4. Active promotions are filtered by date range AND active flag

Notifications

  1. Notifications are org-scoped, optionally store-scoped
  2. metadata is freeform JSON (e.g., {"order_id": "...", "severity": "high"})
  3. Mark-all-read affects only the requesting org's notifications

Stock Transfers

  1. Both stores must belong to the same org
  2. Status flow: pending -> shipped -> received (or cancelled)
  3. On receive, inventory is decremented at source and incremented at destination
  4. received_qty may differ from requested quantity (partial receipt)
  5. Creating a transfer does NOT immediately affect inventory — only receive does

Activity Logs

  1. Logs are write-once (no update/delete endpoints)
  2. Created automatically by service methods (e.g., "user_added", "product_updated")
  3. details is JSONB containing contextual data
  4. actor_type distinguishes user actions from system actions

Acceptance Criteria

  1. Customer CRUD works with loyalty points adjustment
  2. Customer search returns results matching name
  3. Promotion CRUD works with product/store linking
  4. Promotion toggle and active filtering works
  5. Notification CRUD works with unread count and mark-read
  6. Stock transfer lifecycle works: create -> ship -> receive (with inventory updates)
  7. Partial receipt (received_qty != quantity) is handled
  8. Activity log read endpoints work with filtering by store, user, action type
  9. All list endpoints support pagination
  10. Bazel build passes
  11. Unit tests for CustomerService, PromotionService, NotificationService, StockTransferService