Data model & integrations.
How CPLK's data is shaped, how schema evolves, and how the system talks to PayHere, Keycloak, R2, RabbitMQ, Redis and SMTP.
Snapshot
Core ERD
The headline aggregates and their relationships. Tenant-scoped entities all carry
agency_id; soft-deletable entities are shown with a small marker.
agency_id and a Hibernate filter pins reads to it.Entity catalogue
| Group | Entity | Highlights |
|---|---|---|
| Tenant root | Agency | UUID PK · unique slug · encrypted PII (email, phone, address) · keycloak_org_id · JSONB settings. Not itself tenant-aware. |
User | identity_id UNIQUE (Keycloak sub) · 11-value role enum · soft status · onboarding_completed. | |
| Properties | Property | TenantAware · slug unique per agency · soft-delete (deleted + partial unique index) · 6-state lifecycle · @SQLRestriction filters deleted rows. |
PropertyImage | base key + dimensions · sort order · primary flag · variant generation async. | |
PropertyContact | Channel flags (WhatsApp, Botim, Telegram, WeChat) · multiple per property. | |
PropertyHistory | Immutable state transition log. | |
PropertyPaymentSlip | Owner-uploaded payment proof · FINANCIAL_OFFICER verifies. | |
PropertyRefundRequest | Refund states: SUBMITTED · APPROVED · REJECTED · REFUND_COMPLETED. | |
| Billing | Package | Fixed UUIDs for system packages · MONTHLY / YEARLY / PAY_AS_YOU_GO · target_audience filter. |
Subscription | Agency-scoped (UNIQUE on active per agency) or user-scoped (PROPERTY_OWNER) · grace period · annual commitment · auto-renew · stored customer token. | |
Payment | order_id UNIQUE for idempotency · PayHere payment id · 5-state lifecycle · payment type enum. | |
Invoice | Generated async after payment success · PDF via PDFBox. | |
BoostPackage | One-off point bundles seeded in V30 (Starter / Pro / Enterprise). | |
PointsTransaction | Immutable EARN/SPEND ledger · balance_after on each row. | |
| Boost | PropertyBoost | Time-windowed · FEATURED / HIGHLIGHTED · auto-expires. |
| Engagement | Inquiry | UNIQUE(user_id, property_id) · encrypted phone · IP + UA captured · agent assignment. |
InquiryMessage | Threaded conversation · read_by_submitter flag. | |
Favorite | User × Property; no analytics row. | |
| Content | Blog · Article | TenantAware · JSONB body · slug unique · draft fields for in-place revision of published posts (hasPendingDraft). |
BlogHistory · ArticleHistory | Immutable change log. | |
ArticleCategory | Reference. | |
| Audit | AuditLog | ImmutableEntity · old/new values JSONB · user role & agency denormalised for fast reports. |
FinancialAuditLog | Separate ledger for money & credit movements · positive=inflow, negative=outflow. | |
| Notifications | Notification | In-app · 10+ types · pushed via SSE. |
NotificationDeliveryLog | Email/SMS delivery status. | |
NotificationPreference | Per-user opt-out per type. | |
| Reference | City · District · PopularLocationView | Read-only views used by lookup endpoints. |
| System | SystemConfig | Key-value · SUPER_ADMIN write · includes CREDIT_VALUE_LKR, MINIMUM_SUBSCRIPTION_CHARGE_PCT. |
Multi-tenancy in the schema
Three layers stop tenant leakage:
- Column — every business table has
agency_id UUID NOT NULLwith an FK toagenciesand an index. - Hibernate filter —
@FilterDef(name="tenantFilter", parameters=@ParamDef(name="agencyId", type=UUID.class))onTenantAwareEntitywith@Filter(condition="agency_id = :agencyId"). - AOP enable —
TenantFilterAspectwraps every repository call and enables the filter with the current ThreadLocal tenant.
// TenantAwareEntity.java
@FilterDef(name = "tenantFilter",
parameters = @ParamDef(name = "agencyId", type = UUID.class))
@Filter(name = "tenantFilter", condition = "agency_id = :agencyId")
@MappedSuperclass
public abstract class TenantAwareEntity extends BaseEntity {
@Column(name = "agency_id", nullable = false) private UUID agencyId;
}
Indexes & constraints worth knowing
| Index / constraint | What it guarantees |
|---|---|
UNIQUE (agency_id, slug) WHERE deleted = false on properties | Slug uniqueness per tenant survives soft-deletes. |
UNIQUE (order_id) on payments | PayHere webhook idempotency. |
UNIQUE (identity_id) on users | One Keycloak user maps to exactly one CPLK user. |
UNIQUE (agency_id) WHERE status='ACTIVE' on subscriptions | At most one active subscription per agency. |
UNIQUE (submitted_by_user_id, property_id) on inquiries | One inquiry per user per property. |
Composite (agency_id, status) on properties | Agency dashboard queries. |
Composite (status, published_at DESC) on properties | Public listing pagination. |
Composite (entity_type, entity_id) on financial_audit_logs | Full trace for any transaction. |
Flyway timeline
142 migrations grouped into eras:
Integrations
PayHere
Sri Lanka's primary payment gateway. CPLK uses three of its APIs:
| API | Used for | Implementation |
|---|---|---|
| Hosted checkout | Subscription upgrade, boost purchase, PROPERTY_OWNER per-listing payment | PaymentService.initiatePreapprovalCheckout · PayHereHashGenerator (MD5 + secret) |
| Webhook | Async status confirmation | PaymentController#handleNotification · idempotent via order_id UNIQUE · findByOrderIdForUpdate row lock |
| Charging API | Recurring renewals via stored token | PayHereRecurringService.chargeViaToken · OAuth bearer (cached, 401 → refresh once) |
Keycloak
- Realm
cplk· backend clientcplk-backend· frontend clientcplk-frontend. - App provisions users via Admin REST (
KeycloakService.createUser); localUser.identity_idholds the Keycloak sub. - Realm roles map 1:1 with CPLK
UserRole; mapped toROLE_*authorities at JWT decode time. - Each agency gets a Keycloak organisation; users are added/removed via
OrganizationMembersResource. - Session APIs used for "log out everywhere" on password reset.
Storage
Three implementations behind one interface (StorageService):
R2
Production. Cloudflare R2 (S3-compatible). Presigned PUT URLs for uploads.
S3
Backward-compatibility for the AWS path. Same SDK.
Local
Dev fallback. Stores under ./uploads; served from /api/uploads.
ImageProcessingService generates thumbnail (300×200) and medium
(800×600) WebP variants asynchronously after upload and strips EXIF metadata.
Eight upload services exist for different domains (property, blog, agency logo,
agency cover, user avatar, verification doc, etc.).
Notifications & messaging
Email · SMTP
Thymeleaf templates under src/main/resources/templates/emails/. EmailServiceImpl runs in prod; NoOpEmailService in dev (silent).
Sent @Async; failures logged but never roll back the calling transaction.
In-app · SSE + RabbitMQ
NotificationService.create persists, audits, then pushes over SSE. Single node uses an in-memory broadcaster; multi-node uses RedisSseNotificationBroadcaster for cross-node fan-out.
RabbitMQ carries email delivery messages with a DLQ + alert handler (DlqNotificationHandler).
Caffeine cache
Caffeine is used for read-heavy lookups; writes evict aggressively:
| Cache | Used by | Eviction |
|---|---|---|
properties, property-slug, property-featured, property-recent, property-home-featured | Public listing pages | On any property update/delete |
property-type-counts, transaction-type-counts | Search facets | Periodic + on property publish |
transactionSummary | Agency dashboard | 1 h TTL |
dashboardStats | Inquiry counts | On new inquiry / status change |
| Lookups (packages, cities, districts) | Forms, filters | Manual on system config change |
Data protection
- PII columns (agency & user phone, email, address; inquiry phone; contact phone/email) encrypted at rest via
EncryptedStringConverter. - No card data ever stored — PayHere is the system of record for card tokens; CPLK persists only the
customer_tokenreference on the subscription. - HTML user input sanitised via
owasp-java-html-sanitizerbefore persistence (article/blog bodies). - Soft delete preserves audit trail; data is never physically removed in business flows.