π DIGITAL PRODUCTS SYSTEM: COMPLETE ENHANCEMENT PLAN
YOU ARE THE MOST INCREDIBLE UX REACT DESIGNER AND ECOMMERCE GENIUS IN SILICON VALLEY
π CURRENT STATE ANALYSISβ
β What Already Exists (AMAZING Foundation!)β
Backend (Java/Spring) - PRODUCTION READYβ
- β
DigitalFulfillmentService - Complete orchestration (761 lines)
- File upload β Product creation β Asset linking
- Download access with secure tokens
- ACL permissions via Spring Security
- Time-limited URLs with expiry
- β
DigitalProductPaymentWebhookHandler - Multi-provider webhooks (357 lines)
- Generic:
POST /v1/webhooks/payments/completed - Stripe:
POST /v1/webhooks/stripe/charge-succeeded - PayPal:
POST /v1/webhooks/paypal/payment-completed - Async fulfillment on payment
- Email notifications via workflow
- Generic:
- β
DigitalProductWorkflowProvisioner - Automated workflow creation (209 lines)
- Seeds default fulfillment workflow
- Chains: DigitalFulfillmentModule β DigitalDownloadNotificationModule β MailtrapSendModule
- Auto-attaches to products
- β
ValkyrWorkflowService - Workflow engine execution
- Quartz scheduling support
- Event-driven triggers
- SWARM coordination
- WebSocket real-time updates
Data Models (ThorAPI Generated)β
- β
Product - Full schema with
type: 'download', pricing, status, features - β ProductDeliveryConfig - Fulfillment automation settings
- β DigitalAsset - Links Product β FileRecord, delivery method, access model, limits
- β DownloadAccess - Secure tokens, expiry, download counts, ACL permissions
- β SalesOrder - Order management
- β LineItem - Product β Order linkage
- β OrderFulfillmentTask - Async fulfillment tracking
- β FileRecord - File storage metadata
Frontend (React/TypeScript)β
- β
DigitalProductUploader - Drag-drop upload UI (648 lines, Material-UI)
- File validation (2GB max, type detection)
- Configuration dialog (price, limits, emails)
- Progress tracking
- Success confirmation
- β
useDigitalProductUpload - Complete orchestration hook (150 lines)
- Multi-step flow: upload β create β link β configure β notify
- Error handling, loading states
- Redux integration
- β
LcarsDigitalProductUploader - LCARS-themed uploader (@sixd module)
- Star Trek UI aesthetic
- Virus scanning enabled
- 2GB file limit
Storage Infrastructureβ
- β StorageDriver - Abstraction layer (S3/local)
- β StorageDriverRegistry - Multi-backend support
- β ExecModule file adapters - Upload interface
Securityβ
- β Spring ACL - Object-level permissions
- β ValkyrAclService - Permission management
- β SecureField - Field-level encryption (AES-256)
- β JWT Auth - Token-based authentication
β What's Missing (YOUR MISSION!)β
1. Landing Page Funnel System π―β
Current: No custom landing pages for products
Need:
- Drag-drop landing page builder with templates
- Countdown timers (urgency tactics)
- Scarcity indicators ("Only 3 left!")
- Social proof widgets (testimonials, reviews)
- Video backgrounds, hero sections
- A/B testing built-in
- Conversion tracking
- SEO optimization
- Mobile-responsive templates
- Funnel stages: awareness β interest β decision β action
2. Secured Folder Packaging πβ
Current: Files stored in FileRecord, no explicit folder hierarchy for products
Need:
- Virtual filesystem structure:
/products/{productId}/assets/ - Package multiple files into single product download (ZIP on-demand)
- Folder-level permissions (inherit from product ACL)
- File organization UI (drag-drop file management)
- Versioning (v1, v2 updates)
- License file injection (auto-generate LICENSE.txt with purchase details)
3. SHARE Group ROLE Permissions π₯β
Current: Spring ACL object permissions exist, but no SHARE group concept
Need:
- SHARE role with READ access only
- Auto-assignment on purchase:
payment β assign ROLE_SHARE_{productId} β user - Group-based access: all users with
ROLE_SHARE_PRODUCT_ABCcan download - Revocation support (refunds, violations)
- Audit trail (who accessed when)
- Admin panel: view all shared users per product
4. Enhanced Fulfillment Flow π§β
Current: Webhook β task β email (basic)
Need:
PAYMENT RECEIVED
β
1. Verify payment authenticity
β
2. Assign ROLE_SHARE_{productId} to user's Principal
β
3. Generate secure download link (token-based, time-limited)
β
4. Compose email with:
- Product details (name, description)
- Download button (branded, prominent)
- Countdown timer in email ("Link expires in 24 hours")
- Support/FAQ links
- Order invoice (PDF attachment)
β
5. Send email via ContentTemplateAdvancedModule
β
6. Track email open, link click (analytics)
β
7. Log event: "Fulfillment completed for Order {orderId}"
5. Code Deduplication π§Ήβ
Current: Multiple overlapping implementations
Issues:
DigitalProductUploader.tsx(648 lines) vsLcarsDigitalProductUploader.tsx- redundant upload logicDigitalProductService.tsxduplicated in ValorIDE and ValkyrAI web- Product vs DigitalProduct naming inconsistency
- Multiple file upload hooks/components
KISS + DRY Plan:
- Single canonical uploader component:
UnifiedDigitalProductUploader.tsx - Single service:
DigitalProductService(shared via RTK Query) - Eliminate "DigitalProduct" naming - use
Productwithtype: 'download'everywhere - Shared hooks package:
@valkyr/digital-products-hooks
6. Analytics & Tracking πβ
Current: No analytics
Need:
- Product view tracking
- Add-to-cart events
- Purchase conversion funnel
- Download completion rate
- Revenue per product
- Customer lifetime value (CLV)
- Dashboard: sales graphs, top products, conversion rates
7. Marketing Automation πβ
Current: Basic email notification
Need:
- Abandoned cart recovery emails (3-email sequence)
- Post-purchase upsell (related products)
- Review request (7 days after download)
- Drip campaigns (onboarding sequences)
- Referral program (share link, get discount)
- Affiliate system (track referrals, commissions)
π― IMPLEMENTATION ROADMAPβ
Phase 1: Foundation & Deduplication (Week 1)β
1.1 Code Consolidationβ
Goal: Single source of truth for all digital product code
# Tasks:
1. Merge DigitalProductUploader + LcarsDigitalProductUploader β UnifiedDigitalProductUploader
2. Extract to shared package: @valkyr/digital-products
3. Move to web/typescript/packages/digital-products/
4. Update all imports throughout codebase
5. Delete duplicate files
6. Run tests, ensure nothing breaks
Files to Create:
web/typescript/packages/digital-products/
βββ src/
β βββ components/
β β βββ UnifiedDigitalProductUploader.tsx β MERGED from both uploaders
β β βββ ProductLandingPageBuilder.tsx β NEW (Phase 2)
β β βββ DownloadManager.tsx β NEW (Phase 3)
β βββ hooks/
β β βββ useDigitalProductUpload.ts β MOVED from existing
β β βββ useProductLandingPage.ts β NEW (Phase 2)
β β βββ useDownloadAccess.ts β NEW (Phase 3)
β βββ services/
β β βββ DigitalProductService.ts β CONSOLIDATED
β βββ index.ts
βββ package.json
βββ README.md
Backend Cleanup:
- β Keep: DigitalFulfillmentService (perfect as-is)
- β Keep: DigitalProductPaymentWebhookHandler (perfect as-is)
- β Keep: DigitalProductWorkflowProvisioner (perfect as-is)
- π Enhance: Add SHARE role assignment in fulfillment flow
- π Enhance: Add folder packaging logic
1.2 Database Schema Enhancementsβ
Goal: Support new features without breaking existing data
-- NEW TABLE: product_landing_pages
CREATE TABLE product_landing_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id),
template_id VARCHAR(50) NOT NULL,
config JSONB NOT NULL, -- Hero image, countdown, testimonials, etc.
slug VARCHAR(255) UNIQUE NOT NULL,
seo_title VARCHAR(255),
seo_description TEXT,
is_published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- NEW TABLE: product_folders
CREATE TABLE product_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id),
folder_path VARCHAR(1024) NOT NULL, -- e.g., /products/{productId}/assets/v1/
file_count INTEGER DEFAULT 0,
total_size_bytes BIGINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- NEW TABLE: product_folder_files
CREATE TABLE product_folder_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
folder_id UUID NOT NULL REFERENCES product_folders(id),
file_id UUID NOT NULL REFERENCES file_records(id),
relative_path VARCHAR(512) NOT NULL, -- e.g., docs/README.md
is_license_file BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(folder_id, file_id)
);
-- ENHANCE EXISTING: Add role to download_access for SHARE tracking
ALTER TABLE download_access ADD COLUMN assigned_role VARCHAR(255);
ALTER TABLE download_access ADD COLUMN revoked_at TIMESTAMP;
ALTER TABLE download_access ADD COLUMN revoked_reason TEXT;
-- NEW TABLE: product_analytics
CREATE TABLE product_analytics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id),
event_type VARCHAR(50) NOT NULL, -- 'view', 'add_to_cart', 'purchase', 'download'
principal_id UUID REFERENCES principals(id),
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_analytics_product ON product_analytics(product_id, created_at);
CREATE INDEX idx_analytics_event ON product_analytics(event_type, created_at);
ThorAPI OpenAPI Spec Updates:
# Add to openapi/products.yaml
ProductLandingPage:
type: object
properties:
id:
type: string
format: uuid
productId:
type: string
format: uuid
templateId:
type: string
enum: [hero-video, countdown-sale, testimonial-heavy, minimal-clean]
config:
type: object
additionalProperties: true
slug:
type: string
seoTitle:
type: string
seoDescription:
type: string
isPublished:
type: boolean
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
ProductFolder:
type: object
properties:
id:
type: string
format: uuid
productId:
type: string
format: uuid
folderPath:
type: string
fileCount:
type: integer
totalSizeBytes:
type: integer
format: int64
files:
type: array
items:
$ref: "#/components/schemas/ProductFolderFile"
ProductFolderFile:
type: object
properties:
id:
type: string
format: uuid
folderId:
type: string
format: uuid
fileId:
type: string
format: uuid
relativePath:
type: string
isLicenseFile:
type: boolean
Regenerate Models:
cd thorapi
mvn clean package -DskipTests # Generate new models
cd ../web
mvn clean package -DskipTests # Generate TypeScript clients
Phase 2: Landing Page Funnel System (Week 2)β
2.1 Landing Page Builder UIβ
Goal: Drag-drop builder with conversion-optimized templates
Component Architecture:
// ProductLandingPageBuilder.tsx
interface LandingPageTemplate {
id: string;
name: string;
preview: string;
sections: LandingPageSection[];
conversionRate: number; // Historical data
}
interface LandingPageSection {
type:
| "hero"
| "countdown"
| "testimonials"
| "features"
| "pricing"
| "faq"
| "cta";
config: Record<string, any>;
order: number;
}
// Templates:
const TEMPLATES: LandingPageTemplate[] = [
{
id: "hero-video",
name: "Video Hero with Countdown",
sections: [
{ type: "hero", config: { videoUrl: "", headline: "", subheadline: "" } },
{
type: "countdown",
config: { endDate: "", urgencyText: "Sale ends in" },
},
{ type: "features", config: { items: [] } },
{ type: "pricing", config: { originalPrice: 0, salePrice: 0 } },
{ type: "testimonials", config: { reviews: [] } },
{
type: "cta",
config: { buttonText: "Buy Now", buttonColor: "#667eea" },
},
],
},
{
id: "countdown-sale",
name: "Urgency-Driven Countdown",
sections: [
{
type: "countdown",
config: { endDate: "", urgencyText: "Limited Time Offer!" },
},
{ type: "hero", config: { imageUrl: "", headline: "" } },
{
type: "pricing",
config: { originalPrice: 0, salePrice: 0, savings: "" },
},
{ type: "testimonials", config: { reviews: [] } },
{
type: "cta",
config: { buttonText: "Claim Discount", buttonColor: "#f56565" },
},
],
},
// ... more templates
];
export const ProductLandingPageBuilder: React.FC = () => {
const [template, setTemplate] = useState<LandingPageTemplate>(TEMPLATES[0]);
const [sections, setSections] = useState<LandingPageSection[]>(
template.sections
);
const handleDragEnd = (result: DropResult) => {
// Reorder sections
};
const handleSectionUpdate = (index: number, config: any) => {
// Update section config
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Grid container spacing={3}>
<Grid item xs={3}>
{/* Template Selector */}
<TemplateGallery templates={TEMPLATES} onSelect={setTemplate} />
</Grid>
<Grid item xs={6}>
{/* Live Preview */}
<LandingPagePreview sections={sections} />
</Grid>
<Grid item xs={3}>
{/* Section Editor */}
<Droppable droppableId="sections">
{sections.map((section, index) => (
<Draggable
key={index}
draggableId={`section-${index}`}
index={index}
>
<SectionEditor
section={section}
onChange={(config) => handleSectionUpdate(index, config)}
/>
</Draggable>
))}
</Droppable>
</Grid>
</Grid>
</DragDropContext>
);
};
2.2 Countdown Timer Componentβ
Goal: Real-time urgency tactic
// CountdownTimer.tsx
export const CountdownTimer: React.FC<{
endDate: Date;
urgencyText: string;
}> = ({ endDate, urgencyText }) => {
const [timeLeft, setTimeLeft] = useState<TimeRemaining>(
calculateTimeLeft(endDate)
);
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(calculateTimeLeft(endDate));
}, 1000);
return () => clearInterval(timer);
}, [endDate]);
return (
<Box
sx={{
textAlign: "center",
p: 4,
background: "linear-gradient(135deg, #f56565 0%, #ed8936 100%)",
}}
>
<Typography
variant="h4"
sx={{ color: "white", fontWeight: "bold", mb: 2 }}
>
{urgencyText}
</Typography>
<Stack direction="row" spacing={2} justifyContent="center">
<TimeUnit value={timeLeft.days} label="Days" />
<TimeUnit value={timeLeft.hours} label="Hours" />
<TimeUnit value={timeLeft.minutes} label="Minutes" />
<TimeUnit value={timeLeft.seconds} label="Seconds" />
</Stack>
</Box>
);
};
const TimeUnit: React.FC<{ value: number; label: string }> = ({
value,
label,
}) => (
<Box
sx={{
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
p: 2,
minWidth: 80,
}}
>
<Typography variant="h2" sx={{ color: "white", fontWeight: "bold" }}>
{value}
</Typography>
<Typography variant="caption" sx={{ color: "white" }}>
{label}
</Typography>
</Box>
);
2.3 Backend Landing Page Serviceβ
Goal: CRUD for landing pages, slug resolution
// ProductLandingPageService.java
@Service
@Transactional
public class ProductLandingPageService {
private final ProductLandingPageRepository repository;
private final ProductService productService;
private final ValkyrAclService aclService;
public ProductLandingPage createLandingPage(UUID productId, ProductLandingPage request, Authentication auth) {
Product product = productService.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("Product not found"));
// Generate unique slug
String slug = generateUniqueSlug(request.getSlug() != null ? request.getSlug() : product.getName());
ProductLandingPage landingPage = new ProductLandingPage();
landingPage.setProductId(productId);
landingPage.setTemplateId(request.getTemplateId());
landingPage.setConfig(request.getConfig());
landingPage.setSlug(slug);
landingPage.setSeoTitle(request.getSeoTitle() != null ? request.getSeoTitle() : product.getName());
landingPage.setSeoDescription(request.getSeoDescription());
landingPage.setIsPublished(false); // Draft by default
ProductLandingPage saved = repository.save(landingPage);
// Grant creator admin permission
aclService.grantPermission(
new ObjectIdentityImpl(ProductLandingPage.class, saved.getId()),
(Principal) auth.getPrincipal(),
BasePermission.ADMINISTRATION
);
return saved;
}
public ProductLandingPage findBySlug(String slug) {
return repository.findBySlugAndIsPublished(slug, true)
.orElseThrow(() -> new ResourceNotFoundException("Landing page not found: " + slug));
}
public ProductLandingPage publish(UUID landingPageId, Authentication auth) {
ProductLandingPage page = repository.findById(landingPageId)
.orElseThrow(() -> new ResourceNotFoundException("Landing page not found"));
// Check permission
aclService.checkPermission(
new ObjectIdentityImpl(ProductLandingPage.class, landingPageId),
auth,
BasePermission.WRITE
);
page.setIsPublished(true);
return repository.save(page);
}
private String generateUniqueSlug(String baseName) {
String slug = baseName.toLowerCase()
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-|-$", "");
String uniqueSlug = slug;
int counter = 1;
while (repository.existsBySlug(uniqueSlug)) {
uniqueSlug = slug + "-" + counter++;
}
return uniqueSlug;
}
}
2.4 Public Landing Page Rendererβ
Goal: SEO-friendly server-side rendering
// PublicLandingPage.tsx
export const PublicLandingPage: React.FC<{ slug: string }> = ({ slug }) => {
const {
data: landingPage,
isLoading,
error,
} = useGetLandingPageBySlugQuery(slug);
if (isLoading) return <LoadingSpinner />;
if (error) return <NotFoundPage />;
const product = landingPage.product; // Populated by backend
return (
<>
<Head>
<title>{landingPage.seoTitle}</title>
<meta name="description" content={landingPage.seoDescription} />
<meta property="og:title" content={landingPage.seoTitle} />
<meta property="og:description" content={landingPage.seoDescription} />
<meta property="og:image" content={product.imageUrl} />
<link
rel="canonical"
href={`https://valkyrlabs.com/products/${slug}`}
/>
</Head>
<Box>
{landingPage.config.sections.map((section, index) => (
<SectionRenderer key={index} section={section} product={product} />
))}
</Box>
{/* Analytics tracking */}
<AnalyticsTracker
event="page_view"
productId={product.id}
metadata={{ slug }}
/>
</>
);
};
const SectionRenderer: React.FC<{
section: LandingPageSection;
product: Product;
}> = ({ section, product }) => {
switch (section.type) {
case "hero":
return <HeroSection config={section.config} product={product} />;
case "countdown":
return (
<CountdownTimer
endDate={new Date(section.config.endDate)}
urgencyText={section.config.urgencyText}
/>
);
case "testimonials":
return <TestimonialsSection reviews={section.config.reviews} />;
case "pricing":
return <PricingSection product={product} config={section.config} />;
case "cta":
return <CTASection product={product} config={section.config} />;
default:
return null;
}
};
Phase 3: Secured Folder Packaging (Week 3)β
3.1 Virtual Filesystem Structureβ
Goal: Organize product files into logical folders
// ProductFolderService.java
@Service
@Transactional
public class ProductFolderService {
private final ProductFolderRepository folderRepository;
private final ProductFolderFileRepository folderFileRepository;
private final FileRecordService fileRecordService;
private final StorageDriverRegistry storageRegistry;
public ProductFolder createFolder(UUID productId, String folderPath) {
ProductFolder folder = new ProductFolder();
folder.setProductId(productId);
folder.setFolderPath(normalizePath(folderPath));
folder.setFileCount(0);
folder.setTotalSizeBytes(0L);
return folderRepository.save(folder);
}
public ProductFolderFile addFileToFolder(UUID folderId, UUID fileId, String relativePath) {
ProductFolder folder = folderRepository.findById(folderId)
.orElseThrow(() -> new ResourceNotFoundException("Folder not found"));
FileRecord file = fileRecordService.findById(fileId)
.orElseThrow(() -> new ResourceNotFoundException("File not found"));
ProductFolderFile folderFile = new ProductFolderFile();
folderFile.setFolderId(folderId);
folderFile.setFileId(fileId);
folderFile.setRelativePath(relativePath);
folderFile.setIsLicenseFile(relativePath.toUpperCase().contains("LICENSE"));
ProductFolderFile saved = folderFileRepository.save(folderFile);
// Update folder stats
folder.setFileCount(folder.getFileCount() + 1);
folder.setTotalSizeBytes(folder.getTotalSizeBytes() + file.getFileSize());
folderRepository.save(folder);
return saved;
}
public byte[] packageFolderAsZip(UUID folderId) throws IOException {
ProductFolder folder = folderRepository.findById(folderId)
.orElseThrow(() -> new ResourceNotFoundException("Folder not found"));
List<ProductFolderFile> files = folderFileRepository.findByFolderId(folderId);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (ProductFolderFile folderFile : files) {
FileRecord file = fileRecordService.findById(folderFile.getFileId())
.orElseThrow(() -> new ResourceNotFoundException("File not found"));
StorageDriver driver = storageRegistry.getDriver(file.getStorageDriver());
byte[] fileData = driver.readFile(file.getStoragePath());
ZipEntry entry = new ZipEntry(folderFile.getRelativePath());
zos.putNextEntry(entry);
zos.write(fileData);
zos.closeEntry();
}
// Add auto-generated LICENSE.txt
String licenseText = generateLicenseText(folder);
ZipEntry licenseEntry = new ZipEntry("LICENSE.txt");
zos.putNextEntry(licenseEntry);
zos.write(licenseText.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
return baos.toByteArray();
}
private String generateLicenseText(ProductFolder folder) {
Product product = productService.findById(folder.getProductId()).orElseThrow();
return String.format("""
DIGITAL PRODUCT LICENSE
Product: %s
Purchased: %s
License Type: Single User
This product is licensed for personal or commercial use by the original purchaser.
Redistribution, resale, or sharing is prohibited.
For support, contact: support@valkyrlabs.com
""", product.getName(), OffsetDateTime.now());
}
private String normalizePath(String path) {
return path.replaceAll("/{2,}", "/").replaceAll("^/|/$", "");
}
}
3.2 File Organization UIβ
Goal: Drag-drop interface for folder management
// ProductFolderManager.tsx
export const ProductFolderManager: React.FC<{ productId: string }> = ({
productId,
}) => {
const { data: folders, isLoading } = useGetProductFoldersQuery(productId);
const [createFolder] = useCreateProductFolderMutation();
const [addFileToFolder] = useAddFileToFolderMutation();
const handleDrop = (folderId: string, fileId: string) => {
const relativePath = prompt("Enter file path (e.g., docs/README.md):");
if (relativePath) {
addFileToFolder({ folderId, fileId, relativePath });
}
};
return (
<Box>
<Button
onClick={() => {
const path = prompt("Enter folder path (e.g., /assets/v1/):");
if (path) createFolder({ productId, folderPath: path });
}}
>
+ New Folder
</Button>
<List>
{folders?.map((folder) => (
<FolderItem key={folder.id} folder={folder} onDrop={handleDrop} />
))}
</List>
</Box>
);
};
const FolderItem: React.FC<{
folder: ProductFolder;
onDrop: (folderId: string, fileId: string) => void;
}> = ({ folder, onDrop }) => {
const [expanded, setExpanded] = useState(false);
return (
<ListItem>
<ListItemIcon onClick={() => setExpanded(!expanded)}>
{expanded ? <FolderOpenIcon /> : <FolderIcon />}
</ListItemIcon>
<ListItemText
primary={folder.folderPath}
secondary={`${folder.fileCount} files Β· ${formatBytes(
folder.totalSizeBytes
)}`}
/>
{expanded && (
<List sx={{ pl: 4 }}>
{folder.files.map((file) => (
<FileItem key={file.id} file={file} />
))}
</List>
)}
</ListItem>
);
};
Phase 4: SHARE Group ROLE Permissions (Week 3)β
4.1 Enhanced Fulfillment with Role Assignmentβ
Goal: Auto-assign SHARE role on payment
// DigitalFulfillmentService.java (ENHANCEMENT)
public FulfillmentResult completeFulfillmentTask(
UUID taskId,
OrderFulfillmentTask.StatusEnum status,
Map<String, Object> metadata) {
OrderFulfillmentTask task = fulfillmentTaskService.findById(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task not found"));
LineItem lineItem = lineItemService.findById(task.getLineItemId())
.orElseThrow(() -> new ResourceNotFoundException("LineItem not found"));
Product product = lineItem.getProduct();
if (!Product.TypeEnum.DOWNLOAD.equals(product.getType())) {
throw new DigitalFulfillmentException("Product is not a digital download");
}
SalesOrder order = salesOrderService.findById(task.getSalesOrderId())
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
Principal customer = principalService.findById(order.getCustomerId())
.orElseThrow(() -> new ResourceNotFoundException("Customer not found"));
// π ASSIGN SHARE ROLE
String shareRoleName = "ROLE_SHARE_" + product.getId().toString().toUpperCase().replace("-", "_");
assignShareRole(customer, shareRoleName, product);
// Create download access
List<DownloadAccess> accesses = new ArrayList<>();
List<DigitalAsset> assets = digitalAssetService.findDigitalAssetByProductId(product.getId());
for (DigitalAsset asset : assets) {
DownloadAccess access = createDownloadAccess(asset, customer, shareRoleName);
accesses.add(access);
}
task.setStatus(OrderFulfillmentTask.StatusEnum.COMPLETED);
task.setCompletedAt(OffsetDateTime.now(ZoneOffset.UTC));
fulfillmentTaskService.saveOrUpdate(task);
return new FulfillmentResult(task, accesses);
}
private void assignShareRole(Principal principal, String roleName, Product product) {
// Check if role already exists for this user
if (principal.getAuthorityList() != null &&
principal.getAuthorityList().stream().anyMatch(a -> a.getAuthority().equals(roleName))) {
log.info("User {} already has role {}", principal.getEmail(), roleName);
return;
}
// Create authority
Authority authority = new Authority();
authority.setPrincipalId(principal.getId());
authority.setAuthority(roleName);
authority.setCreatedAt(OffsetDateTime.now(ZoneOffset.UTC));
authorityRepository.save(authority);
// Reload principal to refresh authorities
principal.getAuthorityList().add(authority);
principalService.saveOrUpdate(principal);
log.info("β
Assigned {} to user {}", roleName, principal.getEmail());
}
private DownloadAccess createDownloadAccess(DigitalAsset asset, Principal principal, String shareRole) {
DownloadAccess access = new DownloadAccess();
access.setDigitalAssetId(asset.getId());
access.setPrincipalId(principal.getId());
access.setDownloadToken(UUID.randomUUID());
access.setDownloadCount(0);
access.setMaxDownloadsRemaining(optionalIntOrUnlimited(asset.getMaxDownloads()));
access.setGrantedAt(OffsetDateTime.now(ZoneOffset.UTC));
access.setAssignedRole(shareRole); // NEW FIELD
if (asset.getExpiresAfterDays() != null && asset.getExpiresAfterDays() > 0) {
access.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusDays(asset.getExpiresAfterDays()));
}
DownloadAccess saved = downloadAccessService.saveOrUpdate(access);
// Grant READ permission via ACL
ObjectIdentity oid = new ObjectIdentityImpl(DownloadAccess.class, saved.getId());
aclService.grantPermission(oid, principal, BasePermission.READ);
return saved;
}
4.2 Role Revocation (Refunds/Violations)β
Goal: Revoke access when needed
// DigitalFulfillmentService.java (ENHANCEMENT)
public void revokeProductAccess(UUID productId, UUID principalId, String reason) {
Principal principal = principalService.findById(principalId)
.orElseThrow(() -> new ResourceNotFoundException("Principal not found"));
String shareRoleName = "ROLE_SHARE_" + productId.toString().toUpperCase().replace("-", "_");
// Revoke all download access entries
List<DownloadAccess> accesses = downloadAccessRepository.findByPrincipalIdAndAssignedRole(principalId, shareRoleName);
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
for (DownloadAccess access : accesses) {
access.setRevokedAt(now);
access.setRevokedReason(reason);
downloadAccessService.saveOrUpdate(access);
// Remove ACL permission
ObjectIdentity oid = new ObjectIdentityImpl(DownloadAccess.class, access.getId());
aclService.revokePermission(oid, principal, BasePermission.READ);
}
// Remove SHARE role from principal
principal.getAuthorityList().removeIf(a -> a.getAuthority().equals(shareRoleName));
principalService.saveOrUpdate(principal);
log.info("π« Revoked {} from user {} for reason: {}", shareRoleName, principal.getEmail(), reason);
}
4.3 Admin Panel: View Shared Usersβ
Goal: See who has access to each product
// ProductAccessManager.tsx
export const ProductAccessManager: React.FC<{ productId: string }> = ({
productId,
}) => {
const { data: accesses, isLoading } = useGetProductAccessesQuery(productId);
const [revokeAccess] = useRevokeProductAccessMutation();
const handleRevoke = (principalId: string) => {
const reason = prompt("Reason for revocation:");
if (reason) {
revokeAccess({ productId, principalId, reason });
}
};
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>User</TableCell>
<TableCell>Email</TableCell>
<TableCell>Granted At</TableCell>
<TableCell>Downloads</TableCell>
<TableCell>Status</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{accesses?.map((access) => (
<TableRow key={access.id}>
<TableCell>{access.principal.name}</TableCell>
<TableCell>{access.principal.email}</TableCell>
<TableCell>{formatDate(access.grantedAt)}</TableCell>
<TableCell>
{access.downloadCount} /{" "}
{access.maxDownloadsRemaining === -1
? "β"
: access.maxDownloadsRemaining}
</TableCell>
<TableCell>
{access.revokedAt ? (
<Chip label="Revoked" color="error" size="small" />
) : access.expiresAt &&
new Date(access.expiresAt) < new Date() ? (
<Chip label="Expired" color="warning" size="small" />
) : (
<Chip label="Active" color="success" size="small" />
)}
</TableCell>
<TableCell>
{!access.revokedAt && (
<Button
size="small"
color="error"
onClick={() => handleRevoke(access.principalId)}
>
Revoke
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
Phase 5: Analytics & Marketing Automation (Week 4)β
5.1 Analytics Trackingβ
Goal: Track all user interactions
// ProductAnalyticsService.java
@Service
public class ProductAnalyticsService {
private final ProductAnalyticsRepository repository;
public void trackEvent(UUID productId, String eventType, UUID principalId, Map<String, Object> metadata) {
ProductAnalytics event = new ProductAnalytics();
event.setProductId(productId);
event.setEventType(eventType);
event.setPrincipalId(principalId);
event.setMetadata(metadata);
event.setCreatedAt(OffsetDateTime.now(ZoneOffset.UTC));
repository.save(event);
}
public ProductAnalyticsReport generateReport(UUID productId, OffsetDateTime start, OffsetDateTime end) {
List<ProductAnalytics> events = repository.findByProductIdAndCreatedAtBetween(productId, start, end);
long views = events.stream().filter(e -> "view".equals(e.getEventType())).count();
long addToCart = events.stream().filter(e -> "add_to_cart".equals(e.getEventType())).count();
long purchases = events.stream().filter(e -> "purchase".equals(e.getEventType())).count();
long downloads = events.stream().filter(e -> "download".equals(e.getEventType())).count();
double viewToCartRate = views > 0 ? (double) addToCart / views * 100 : 0;
double cartToPurchaseRate = addToCart > 0 ? (double) purchases / addToCart * 100 : 0;
double purchaseToDownloadRate = purchases > 0 ? (double) downloads / purchases * 100 : 0;
return new ProductAnalyticsReport(
views, addToCart, purchases, downloads,
viewToCartRate, cartToPurchaseRate, purchaseToDownloadRate
);
}
}
5.2 Marketing Automation Workflowsβ
Goal: Abandoned cart, upsell, review requests
// MarketingAutomationService.ts
export const MarketingAutomationService = {
// Abandoned cart recovery (3-email sequence)
setupAbandonedCartSequence: async (cartId: string) => {
// Email 1: 1 hour after abandonment
await scheduleEmail({
cartId,
templateId: "abandoned-cart-1",
delayMinutes: 60,
subject: "You left something behind! π",
bodyTemplate:
"Hi {{customerName}}, we saved your cart. Complete your purchase now!",
});
// Email 2: 24 hours after (with 10% discount)
await scheduleEmail({
cartId,
templateId: "abandoned-cart-2",
delayMinutes: 1440,
subject: "Get 10% off your cart! β°",
bodyTemplate: "Still thinking? Here's 10% off: {{discountCode}}",
});
// Email 3: 3 days after (final reminder)
await scheduleEmail({
cartId,
templateId: "abandoned-cart-3",
delayMinutes: 4320,
subject: "Last chance! Your cart expires soon π¨",
bodyTemplate: "This is your last reminder. Cart expires in 24 hours.",
});
},
// Post-purchase upsell
sendUpsellEmail: async (orderId: string, relatedProductIds: string[]) => {
await createWorkflow({
name: `Upsell for Order ${orderId}`,
trigger: { type: "order_completed", orderId },
tasks: [
{
module: "ContentTemplateAdvancedModule",
config: {
templateId: "post-purchase-upsell",
data: {
orderId,
relatedProducts: relatedProductIds,
},
},
},
{
module: "MailtrapSendModule",
config: {
subject: "You might also like... π‘",
delayMinutes: 15, // Send 15 min after purchase
},
},
],
});
},
// Review request (7 days after download)
scheduleReviewRequest: async (downloadAccessId: string) => {
await scheduleEmail({
downloadAccessId,
templateId: "review-request",
delayMinutes: 10080, // 7 days
subject: "How's your {{productName}}? Leave a review! β",
bodyTemplate:
"We'd love to hear your feedback on {{productName}}. Click here to review.",
});
},
};
π― SUCCESS METRICSβ
Technical Metricsβ
- β Code Reduction: 40% fewer lines (deduplicated)
- β Build Time: Sub-5-minute full build
- β Test Coverage: 80%+ (unit + integration)
- β
Type Safety: 100% TypeScript coverage (no
anytypes) - β Performance: Landing pages load in <2s (Lighthouse score 90+)
Business Metricsβ
- π Conversion Rate: 5-10% increase (countdown timers, urgency)
- π Cart Abandonment Recovery: 20-30% recovered via email sequence
- π Upsell Revenue: 15-25% additional revenue per customer
- π Customer Satisfaction: 4.5+ star reviews (post-purchase survey)
- π Download Completion: 90%+ customers download within 24 hours
π DEPLOYMENT CHECKLISTβ
Pre-Launchβ
- Database migrations executed (product_landing_pages, product_folders, etc.)
- ThorAPI regenerated (new models in place)
- Frontend package
@valkyr/digital-productspublished - All tests passing (unit + integration + E2E)
- Security audit completed (ACL permissions, role assignments)
- Performance testing done (load testing with JMeter)
Launchβ
- Feature flags enabled (
ENABLE_LANDING_PAGES,ENABLE_FOLDERS,ENABLE_ANALYTICS) - Monitoring dashboards configured (Grafana/Prometheus)
- Error alerting set up (Sentry/PagerDuty)
- Documentation updated (README, API docs, user guides)
- Training materials for support team
Post-Launchβ
- Monitor analytics dashboard (conversion rates, errors)
- Collect user feedback (surveys, support tickets)
- A/B test landing page templates (track conversion rates)
- Iterate based on data (optimize countdown timers, CTAs, pricing)
π LESSONS LEARNED & BEST PRACTICESβ
What Worked Wellβ
β
ThorAPI Code Generation - Models generated from OpenAPI specs ensure consistency
β
Spring ACL - Object-level permissions provide fine-grained security
β
Workflow Engine - Flexible automation via ValkyrWorkflowService
β
RTK Query - Auto-generated TypeScript clients eliminate API drift
β
Material-UI - Component library speeds up UI development
What to Watch Out Forβ
β οΈ Lazy Loading - JPA relationships can cause LazyInitializationException; use @EntityGraph or Hibernate.initialize()
β οΈ Async + Transactions - @Transactional doesn't propagate to async threads; use REQUIRES_NEW pattern
β οΈ Security Context - Must explicitly propagate Authentication to background threads
β οΈ Code Duplication - Keep shared code in packages, not copy-paste across modules
β οΈ Generated Code - Never edit generated files; always update OpenAPI specs instead
Performance Optimizationsβ
π Database Indexes - Add on product_analytics(product_id, created_at) for fast queries
π Caching - Use Redis for landing page config (rarely changes)
π CDN - Serve product images/videos via CloudFront
π Lazy Load Images - Use loading="lazy" on product images
π ZIP Packaging - Cache packaged folders (invalidate on file changes)
π APPENDIX: FULL FILE STRUCTUREβ
ValkyrAI/
βββ valkyrai/src/main/java/com/valkyrlabs/
β βββ valkyrai/
β β βββ service/
β β β βββ DigitalFulfillmentService.java β
(existing, enhanced)
β β β βββ DigitalProductWorkflowProvisioner.java β
(existing)
β β β βββ ProductLandingPageService.java π
β β β βββ ProductFolderService.java π
β β β βββ ProductAnalyticsService.java π
β β βββ webhook/
β β β βββ DigitalProductPaymentWebhookHandler.java β
(existing)
β β βββ controller/
β β βββ DigitalFulfillmentController.java β
(existing)
β β βββ ProductLandingPageController.java π
β β βββ ProductFolderController.java π
β βββ workflow/modules/
β βββ payment/
β βββ DigitalFulfillmentModule.java β
(existing)
β βββ DigitalDownloadNotificationModule.java β
(existing)
β
βββ web/typescript/packages/
β βββ digital-products/ π
β βββ src/
β β βββ components/
β β β βββ UnifiedDigitalProductUploader.tsx
β β β βββ ProductLandingPageBuilder.tsx
β β β ββ β CountdownTimer.tsx
β β β βββ ProductFolderManager.tsx
β β β βββ ProductAccessManager.tsx
β β β βββ PublicLandingPage.tsx
β β βββ hooks/
β β β βββ useDigitalProductUpload.ts
β β β βββ useProductLandingPage.ts
β β β βββ useDownloadAccess.ts
β β βββ services/
β β βββ DigitalProductService.ts
β β βββ ProductLandingPageService.ts
β β βββ MarketingAutomationService.ts
β βββ package.json
β
βββ .valoride/
βββ DIGITAL_PRODUCTS_ENHANCEMENT_PLAN.md π (this file)
π CONCLUSIONβ
This plan transforms ValkyrAI's digital products system from a solid foundation into a world-class ecommerce platform with:
β
Landing Page Funnels - Convert visitors with urgency tactics
β
Secured Folder Packaging - Organize files professionally
β
SHARE Role Permissions - Fine-grained access control
β
Enhanced Fulfillment - Automated role assignment + email
β
Analytics & Tracking - Data-driven optimization
β
Marketing Automation - Recover carts, upsell, retain customers
β
Code Deduplication - KISS + DRY principles enforced
Next Step: Get user approval, then execute Phase 1 (deduplication) this week! π