ProjectR Docs
Backend (Convex)

Schema

Table groups and what each table models.

The full schema lives in convex/schema.ts (~2,200 lines). Better Auth owns users, sessions, accounts, verifications directly (not declared here). All userId fields are v.string() (Better Auth string IDs).

Table groups

GroupTables
Users/identityuserProfiles (role, Shopify link, prefs), avatarCatalog, adminRequests, accountLinks, shopifySyncJobs
BeautybeautyProfiles, productBeautyTags, routineStepConfigs, skinScanSessions
Shopify mirrorshopifyProducts, shopifyVariants, shopifyCollections, collectionProducts
GamificationstampCampaigns, campaignSpecialPositions, userStampProgress, stampHistory, purchaseOrderProcessing, shakeOpportunities (vestigial)
Loyalty miscgamificationStatsDaily, loyaltyCarousels
VouchersvoucherTemplates, vouchers
Cart/checkoutuserCarts, cartItems, checkoutSessions
Ordersorders, orderLineItems
CampaignsduitRayaCampaigns, duitRayaClaimCodes, duitRayaShopifyPool, duitRayaGenerationJobs
ContentcarouselItems, marketingCampaigns
Notificationsnotifications, notificationTemplates, pushTokens, notificationPreferences
EngagementproductComments, commentVotes, userReports, userBlocks, userProductLists, listProducts, productInteractions
WebhookswebhookEvents, webhookPayloads, webhookSubscriptions
DashboardproductOverrides (+localizations), productGroups, productGroupItems, dashboardSettings, exportReports
MisccontactMessages, r2UploadSessions, auditLogs, userAddresses

The eight aggregates (silent-corruption surface)

convex.config.ts mounts eight @convex-dev/aggregate instances. Six are defined in convex/aggregates.ts, and the two claim-code aggregates in convex/duitRaya/aggregate.ts.

Aggregate (export)TableNamespace / sort key / sum
usersByRoleuserProfilesrole / createdAt / 1
orderTotalsordersrevenue or excluded (from financialStatus) / processedAt / totalAmount in cents
ordersByChannelordersrevenue|<channel> or excluded|<channel> / processedAt / totalAmount in cents
productRatingsproductCommentsproductId / createdAt / rating
campaignStampsstampHistorycampaignId / createdAt / stampsEarned
voucherCountsvouchers<status>|<templateId> / createdAt / count only
duitRayaCodeStats (codeStats)duitRayaClaimCodescampaignId / claimedAt ?? 0 / count only
duitRayaCodesByDenom (codesByDenom)duitRayaClaimCodescampaign, prefix and denomination / claimedAt ?? 0 / count only

Every write to vouchers goes through gamificationLoyalty/voucherCounts.ts, and every claim-code write goes through claimCodeAggregates in duitRaya/aggregate.ts, which keeps both claim-code aggregates in step. The points and tier aggregates (pointsTransactionTotals, userTierCounts) were unmounted with points on 2026-10-16.

Clear, backfill, rebuild and probe functions for each aggregate live in convex/migrations/backfill.ts (see Runbooks). clearAllAggregates in convex/migrations/resetAnalytics.ts clears all eight, and the rest of that file deletes customer data.

Other denormalized invariants

  • finalizeOrderRewards writes orders.stampsAwarded, stampShortfallCents and the final stampOutcome after counting the order's stampHistory rows. The webhook, the dashboard link and status edits, the 30-day claim and the reprocess mutation set only the pre-run outcomes (no_app_user, awaiting_payment, needs_review). Never patch these fields by hand. Rerun the order with Reprocess stamps or startOrderRewards with force.
  • orders.buyerEmail, buyerPhone and shippingPhone hold normalized buyer keys (lowercase email, E.164 phone) that the 30-day claim queries by index. Write them through orderBuyerKeys, never raw.
  • activeVouchers:<userId> sharded counter ↔ count of vouchers rows with status: "active" — every status transition must move the counter; repair repairActiveVouchersCounter.
  • unread:<userId> sharded counter ↔ unread in-app notifications — condition shared via countsTowardUnread.

On this page