Digital Product Publishing System (DPPS) — Implementation Plan
Status: Ready to implement
Date: 2025-10-23
Based on: PRD v1.0
Phase 0: Pre-Flight Checklist ✅
Existing Infrastructure Analysis
✅ CONFIRMED: Core Models Present
Product,Invoice,LineItem(ThorAPI-generated)DigitalAsset,DownloadAccess,FileRecordFileUploadSessionwith multipart upload supportFunnelStage,FunnelTemplate,ProductFunnelWizardContentData(referenced in file system)
✅ CONFIRMED: Services & APIs
DigitalFulfillmentService— handles asset fulfillment with ACL grantsFileController— multipart upload endpoints (/files/uploads/init,/files/uploads/complete)StripeCheckoutModule— Stripe payment integrationAclService— permission management with/v1/auth/acl/grantand/v1/auth/acl/revoke
✅ CONFIRMED: Frontend Stack
- RTK Query services auto-generated for all models
- React components with Formik forms
- File upload hooks and reducers (
FileUploadSession)
Phase 1: ThorAPI Schema Extensions (OpenAPI)
1.1 Add DigitalDownload Entity (Missing from Current Schema)
File: valkyrai/src/main/resources/openapi/api.hbs.yaml
# Add to components/schemas section
DigitalDownload:
type: object
required:
- productId
- buyerId
- entitlementType
properties:
id:
type: string
format: uuid
description: Unique identifier
productId:
type: string
format: uuid
description: Product being downloaded
buyerId:
type: string
format: uuid
description: Principal who purchased
salesOrderLineItemId:
type: string
format: uuid
description: LineItem that granted this entitlement
entitlementType:
type: string
enum: [DOWNLOAD, STREAM, LICENSE_KEY]
default: DOWNLOAD
downloadToken:
type: string
format: uuid
description: One-time or time-limited token
downloadCount:
type: integer
default: 0
description: Number of times downloaded
maxDownloads:
type: integer
description: Max allowed downloads (null = unlimited)
grantedAt:
type: string
format: date-time
expiry:
type: string
format: date-time
description: When entitlement expires (null = perpetual)
status:
type: string
enum: [ACTIVE, REVOKED, EXPIRED]
default: ACTIVE
lastAccessedAt:
type: string
format: date-time
1.2 Enhance Product Schema (if missing fields)
Product:
properties:
# Add if not present:
contentDataId:
type: string
format: uuid
description: Reference to uploaded file/asset
deliveryMode:
type: string
enum: [DIGITAL_DOWNLOAD, STREAMING, LICENSE_KEY, PHYSICAL]
default: DIGITAL_DOWNLOAD
maxDownloads:
type: integer
description: Default max downloads per purchase
expiresAfterDays:
type: integer
description: Entitlement validity in days (null = perpetual)
1.3 Add Funnel/FunnelStep Schemas (if missing)
Note: Based on search results, FunnelStage and FunnelTemplate exist. Check if Funnel entity is needed or if we reuse ProductFunnelWizard.
Decision: Reuse ProductFunnelWizard as the funnel orchestrator. No new schema needed.
Phase 2: Backend Implementation (Java/Spring)
2.1 Regenerate ThorAPI Models
cd thorapi
mvn clean install
cd ../valkyrai
mvn clean install -DskipTests
This will generate:
DigitalDownload.java(model)DigitalDownloadRepository.javaDigitalDownloadService.javaDigitalDownloadApi.java(controller)
2.2 Implement DPPS Workflow Services
File: valkyrai/src/main/java/com/valkyrlabs/valkyrai/service/DppsOrchestrationService.java
package com.valkyrlabs.valkyrai.service;
import com.valkyrlabs.model.*;
import com.valkyrlabs.valkyrai.service.DigitalFulfillmentService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.security.acls.model.ObjectIdentity;
import org.springframework.security.acls.domain.ObjectIdentityImpl;
import org.springframework.security.acls.domain.BasePermission;
import java.util.UUID;
import java.util.List;
import java.util.Map;
/**
* Orchestrates DPPS end-to-end flow:
* Upload → Product → Funnel → Checkout → Fulfillment
*/
@Service
@Transactional
public class DppsOrchestrationService {
private final ProductService productService;
private final FileRecordService fileRecordService;
private final DigitalAssetService digitalAssetService;
private final ProductFunnelWizardService funnelWizardService;
private final DigitalFulfillmentService fulfillmentService;
private final ValkyrAclService aclService;
public DppsOrchestrationService(
ProductService productService,
FileRecordService fileRecordService,
DigitalAssetService digitalAssetService,
ProductFunnelWizardService funnelWizardService,
DigitalFulfillmentService fulfillmentService,
ValkyrAclService aclService
) {
this.productService = productService;
this.fileRecordService = fileRecordService;
this.digitalAssetService = digitalAssetService;
this.funnelWizardService = funnelWizardService;
this.fulfillmentService = fulfillmentService;
this.aclService = aclService;
}
/**
* Step 1: Create Product from uploaded FileRecord
*/
public Product createProductFromUpload(UUID fileRecordId, CreateProductRequest request) {
FileRecord file = fileRecordService.findById(fileRecordId)
.orElseThrow(() -> new IllegalArgumentException("FileRecord not found: " + fileRecordId));
// Create DigitalAsset linking Product → FileRecord
DigitalAsset asset = new DigitalAsset();
asset.setFileRecordId(fileRecordId);
asset.setMaxDownloads(request.getMaxDownloads());
asset.setExpiresAfterDays(request.getExpiresAfterDays());
asset = digitalAssetService.saveOrUpdate(asset);
// Create Product (draft)
Product product = new Product();
product.setName(request.getName());
product.setDescription(request.getDescription());
product.setSalePrice(request.getPrice());
product.setProductType(Product.ProductTypeEnum.DIGITAL);
product.setStatus(Product.StatusEnum.DRAFT);
// Store asset reference in product metadata (or add productId to DigitalAsset)
product = productService.saveOrUpdate(product);
// Grant ACL READ to creator
ObjectIdentity oid = new ObjectIdentityImpl(Product.class, product.getId());
aclService.applyOwnershipPermissions(oid, getCurrentUsername());
return product;
}
/**
* Step 2: Publish Product → creates Funnel via ProductFunnelWizard
*/
public ProductFunnelWizard publishProduct(UUID productId, PublishRequest request) {
Product product = productService.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("Product not found"));
product.setStatus(Product.StatusEnum.PUBLISHED);
productService.saveOrUpdate(product);
// Create funnel wizard request
ProductFunnelWizard wizardReq = new ProductFunnelWizard();
wizardReq.setProductId(productId);
wizardReq.setBrand(request.getBrand());
wizardReq.setTargetAudience(request.getTargetAudience());
wizardReq.setPriceAmount(product.getSalePrice());
wizardReq.setDeliveryMode(ProductFunnelWizard.DeliveryModeEnum.DOWNLOAD);
// Start funnel wizard (this triggers backend workflow)
return funnelWizardService.startFunnelWizard(wizardReq);
}
/**
* Helper: Get current authenticated username
*/
private String getCurrentUsername() {
return org.springframework.security.core.context.SecurityContextHolder
.getContext().getAuthentication().getName();
}
// Inner classes for request DTOs
public static class CreateProductRequest {
private String name;
private String description;
private Double price;
private Integer maxDownloads;
private Integer expiresAfterDays;
// getters/setters
}
public static class PublishRequest {
private String brand;
private String targetAudience;
// getters/setters
}
}
2.3 Implement Purchase Fulfillment Workflow
File: valkyrai/src/main/java/com/valkyrlabs/workflow/definitions/DppsPurchaseFulfillmentWorkflow.java
package com.valkyrlabs.workflow.definitions;
import com.valkyrlabs.model.*;
import com.valkyrlabs.workflow.ValkyrWorkflowService;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* Registers DPPS_Purchase_Fulfillment workflow
* Tasks: VerifyPayment → GrantAccess → NotifyBuyer → PostPurchase
*/
@Component
public class DppsPurchaseFulfillmentWorkflow {
private final ValkyrWorkflowService workflowService;
public DppsPurchaseFulfillmentWorkflow(ValkyrWorkflowService workflowService) {
this.workflowService = workflowService;
}
@PostConstruct
public void registerWorkflow() {
Workflow workflow = new Workflow();
workflow.setName("DPPS_Purchase_Fulfillment");
workflow.setDescription("Digital product fulfillment after payment success");
// Task A: Verify Payment (StripeWebhookModule)
Task verifyPayment = new Task();
verifyPayment.setName("VerifyPayment");
verifyPayment.setModuleName("stripeWebhookModule");
verifyPayment.setInputMapping("{\"orderId\": \"${workflow.state.orderId}\"}");
verifyPayment.setOutputMapping("{\"paymentStatus\": \"${task.output.status}\"}");
// Task B: Grant Access (uses DigitalFulfillmentService)
Task grantAccess = new Task();
grantAccess.setName("GrantAccess");
grantAccess.setModuleName("digitalFulfillmentModule"); // Custom module wrapper
grantAccess.setInputMapping("{\"orderId\": \"${workflow.state.orderId}\"}");
grantAccess.setCondition("${workflow.state.paymentStatus == 'SUCCESS'}");
// Task C: Notify Buyer (MailtrapSendModule)
Task notifyBuyer = new Task();
notifyBuyer.setName("NotifyBuyer");
notifyBuyer.setModuleName("mailtrapSendModule");
notifyBuyer.setInputMapping("{ \"to\": \"${workflow.state.buyerEmail}\", " +
"\"template\": \"dpps_download_ready\", " +
"\"variables\": { \"productTitle\": \"${workflow.state.productName}\", " +
"\"downloadUrl\": \"${task.output.downloadUrl}\" } }");
// Task D: Post Purchase (optional)
Task postPurchase = new Task();
postPurchase.setName("PostPurchase");
postPurchase.setModuleName("slackMessageModule");
postPurchase.setInputMapping("{\"message\": \"New purchase: ${workflow.state.productName}\"}");
// Wire tasks
workflow.addTask(verifyPayment);
workflow.addTask(grantAccess);
workflow.addTask(notifyBuyer);
workflow.addTask(postPurchase);
workflowService.saveOrUpdate(workflow);
}
}
2.4 Implement DigitalFulfillmentModule (ExecModule)
File: valkyrai/src/main/java/com/valkyrlabs/workflow/modules/DigitalFulfillmentModule.java
package com.valkyrlabs.workflow.modules;
import com.valkyrlabs.model.*;
import com.valkyrlabs.valkyrai.service.DigitalFulfillmentService;
import com.valkyrlabs.workflow.modules.base.BaseMapIOModule;
import com.valkyrlabs.workflow.modules.base.VModuleAware;
import org.springframework.stereotype.Component;
import java.util.*;
/**
* ExecModule wrapper for DigitalFulfillmentService
* Grants download access + creates DigitalDownload entitlement
*/
@Component("digitalFulfillmentModule")
@VModuleAware
public class DigitalFulfillmentModule extends BaseMapIOModule {
private final DigitalFulfillmentService fulfillmentService;
private final SalesOrderService salesOrderService;
public DigitalFulfillmentModule(
DigitalFulfillmentService fulfillmentService,
SalesOrderService salesOrderService
) {
this.fulfillmentService = fulfillmentService;
this.salesOrderService = salesOrderService;
}
@Override
public Map<String, Object> execute(
Workflow workflow,
Task task,
ExecModule module,
Map<String, Object> input
) {
try {
UUID orderId = UUID.fromString((String) input.get("orderId"));
// Trigger fulfillment (grants ACL + creates DownloadAccess)
SalesOrder order = salesOrderService.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
// Use existing DigitalFulfillmentService to grant access
// (it already handles ACL grants + DownloadAccess creation)
// For each LineItem with digital product, create DigitalDownload entitlement
Map<String, Object> output = new HashMap<>();
output.put("status", "fulfilled");
output.put("orderId", orderId.toString());
output.put("downloadUrl", generateDownloadUrl(orderId));
return output;
} catch (Exception e) {
Map<String, Object> error = new HashMap<>();
error.put("error", e.getMessage());
error.put("status", "failed");
return error;
}
}
private String generateDownloadUrl(UUID orderId) {
// Generate signed download URL via DigitalFulfillmentService
return "https://app.valkyrlabs.com/download/" + orderId.toString();
}
}
2.5 Signed URL Generation (Download Endpoint)
File: valkyrai/src/main/java/com/valkyrlabs/valkyrai/controller/DownloadController.java
package com.valkyrlabs.valkyrai.controller;
import com.valkyrlabs.model.*;
import com.valkyrlabs.valkyrai.service.DigitalFulfillmentService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* Handles secure download link generation and redemption
*/
@RestController
@RequestMapping("/v1/download")
public class DownloadController {
private final DigitalFulfillmentService fulfillmentService;
private final DigitalDownloadService digitalDownloadService;
public DownloadController(
DigitalFulfillmentService fulfillmentService,
DigitalDownloadService digitalDownloadService
) {
this.fulfillmentService = fulfillmentService;
this.digitalDownloadService = digitalDownloadService;
}
/**
* Generate signed download URL for entitled user
* Requires ACL READ permission + active entitlement
*/
@GetMapping("/{digitalDownloadId}")
@PreAuthorize("hasPermission(#digitalDownloadId, 'DigitalDownload', 'READ')")
public ResponseEntity<DownloadUrlResponse> getDownloadUrl(
@PathVariable UUID digitalDownloadId,
@RequestParam(defaultValue = "15") Integer validityMinutes
) {
DigitalDownload entitlement = digitalDownloadService.findById(digitalDownloadId)
.orElseThrow(() -> new IllegalArgumentException("Entitlement not found"));
if (entitlement.getStatus() != DigitalDownload.StatusEnum.ACTIVE) {
throw new IllegalStateException("Entitlement is not active");
}
// Check max downloads
if (entitlement.getMaxDownloads() != null &&
entitlement.getDownloadCount() >= entitlement.getMaxDownloads()) {
throw new IllegalStateException("Download limit reached");
}
// Generate signed URL via fulfillment service
String signedUrl = fulfillmentService.generateSignedDownloadUrl(
entitlement.getProductId(),
entitlement.getBuyerId(),
validityMinutes
);
// Increment download counter
entitlement.setDownloadCount(entitlement.getDownloadCount() + 1);
digitalDownloadService.saveOrUpdate(entitlement);
return ResponseEntity.ok(new DownloadUrlResponse(signedUrl, validityMinutes));
}
public static class DownloadUrlResponse {
private String url;
private Integer expiresInMinutes;
// constructor, getters/setters
}
}
Phase 3: Frontend Implementation (React/RTK Query)
3.1 File Uploader Component (Drag & Drop)
File: web/typescript/valkyr_labs_com/src/components/dpps/FileUploader.tsx
import React, { useState } from "react";
import { useDropzone } from "react-dropzone";
import { usePostFileUploadSessionMutation } from "@thorapi/redux/services/FileUploadSessionService";
import { FileUploadSession } from "@thorapi/model/FileUploadSession";
interface FileUploaderProps {
onUploadComplete: (fileRecordId: string) => void;
}
export const FileUploader: React.FC<FileUploaderProps> = ({
onUploadComplete,
}) => {
const [uploadProgress, setUploadProgress] = useState(0);
const [createSession] = usePostFileUploadSessionMutation();
const onDrop = async (acceptedFiles: File[]) => {
const file = acceptedFiles[0];
// Step 1: Init multipart upload
const initResponse = await fetch("/files/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
mimeType: file.type,
sizeBytes: file.size,
}),
});
const { sessionId, uploadId, uploadUrl, formFields } =
await initResponse.json();
// Step 2: Upload chunks (using presigned URLs)
const chunkSize = 5 * 1024 * 1024; // 5MB
const totalChunks = Math.ceil(file.size / chunkSize);
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
const formData = new FormData();
Object.entries(formFields).forEach(([key, value]) =>
formData.append(key, value)
);
formData.append("file", chunk);
await fetch(uploadUrl, { method: "POST", body: formData });
setUploadProgress(Math.round(((i + 1) / totalChunks) * 100));
}
// Step 3: Complete upload
const completeResponse = await fetch("/files/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, uploadId }),
});
const { fileId } = await completeResponse.json();
onUploadComplete(fileId);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxFiles: 1,
});
return (
<div
{...getRootProps()}
style={{
border: "2px dashed #ccc",
padding: "40px",
textAlign: "center",
cursor: "pointer",
}}
>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop the file here...</p>
) : (
<p>Drag & drop a file, or click to select</p>
)}
{uploadProgress > 0 && <progress value={uploadProgress} max={100} />}
</div>
);
};
3.2 Product Creation Form (Auto-filled from Upload)
File: web/typescript/valkyr_labs_com/src/components/dpps/ProductForm.tsx
import React from "react";
import { Formik, Form, Field } from "formik";
import {
useCreateProductMutation,
useUpdateProductMutation,
} from "@thorapi/redux/services/ProductService";
import { Product } from "@thorapi/model/Product";
interface ProductFormProps {
fileRecordId?: string;
existingProduct?: Product;
onSave: (product: Product) => void;
}
export const ProductForm: React.FC<ProductFormProps> = ({
fileRecordId,
existingProduct,
onSave,
}) => {
const [createProduct] = useCreateProductMutation();
const [updateProduct] = useUpdateProductMutation();
const initialValues = {
name: existingProduct?.name || "",
description: existingProduct?.description || "",
salePrice: existingProduct?.salePrice || 0,
maxDownloads: 5,
expiresAfterDays: null,
status: "DRAFT",
};
const handleSubmit = async (values: any) => {
const productData = {
...values,
contentDataId: fileRecordId,
productType: "DIGITAL",
};
const result = existingProduct
? await updateProduct({ id: existingProduct.id, ...productData }).unwrap()
: await createProduct(productData).unwrap();
onSave(result);
};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
{({ isSubmitting }) => (
<Form>
<div>
<label>Product Name</label>
<Field name="name" type="text" required />
</div>
<div>
<label>Description</label>
<Field name="description" as="textarea" />
</div>
<div>
<label>Price (USD)</label>
<Field name="salePrice" type="number" step="0.01" required />
</div>
<div>
<label>Max Downloads</label>
<Field name="maxDownloads" type="number" />
</div>
<div>
<label>Expires After (days)</label>
<Field
name="expiresAfterDays"
type="number"
placeholder="Leave empty for perpetual"
/>
</div>
<button type="submit" disabled={isSubmitting}>
{existingProduct ? "Update Product" : "Create Product (Draft)"}
</button>
</Form>
)}
</Formik>
);
};
3.3 Publish Workflow Component
File: web/typescript/valkyr_labs_com/src/components/dpps/PublishProduct.tsx
import React, { useState } from "react";
import { useStartFunnelWizardMutation } from "@thorapi/redux/services/ProductFunnelWizardService";
import { Product } from "@thorapi/model/Product";
interface PublishProductProps {
product: Product;
onPublished: (funnelUrl: string) => void;
}
export const PublishProduct: React.FC<PublishProductProps> = ({
product,
onPublished,
}) => {
const [startFunnel, { isLoading }] = useStartFunnelWizardMutation();
const [brand, setBrand] = useState("");
const [targetAudience, setTargetAudience] = useState("");
const handlePublish = async () => {
const wizardRequest = {
productId: product.id,
brand,
targetAudience,
priceAmount: product.salePrice,
deliveryMode: "DOWNLOAD",
};
const result = await startFunnel(wizardRequest).unwrap();
// Poll for wizard completion (or use WebSocket)
// Once complete, call publishFunnel endpoint
const publishResult = await fetch(
`/api/v1/product-funnel-wizard/${result.id}/publish`,
{
method: "POST",
}
);
const { landingPageUrl } = await publishResult.json();
onPublished(landingPageUrl);
};
return (
<div>
<h2>Publish Product</h2>
<div>
<label>Brand Name</label>
<input value={brand} onChange={(e) => setBrand(e.target.value)} />
</div>
<div>
<label>Target Audience</label>
<input
value={targetAudience}
onChange={(e) => setTargetAudience(e.target.value)}
/>
</div>
<button onClick={handlePublish} disabled={isLoading}>
Publish & Generate Funnel
</button>
</div>
);
};
3.4 Main DPPS Wizard Component (Orchestrates Flow)
File: web/typescript/valkyr_labs_com/src/pages/DppsWizard.tsx
import React, { useState } from "react";
import { FileUploader } from "@components/dpps/FileUploader";
import { ProductForm } from "@components/dpps/ProductForm";
import { PublishProduct } from "@components/dpps/PublishProduct";
import { Product } from "@thorapi/model/Product";
type Step = "upload" | "product" | "publish" | "complete";
export const DppsWizard: React.FC = () => {
const [step, setStep] = useState<Step>("upload");
const [fileRecordId, setFileRecordId] = useState<string | null>(null);
const [product, setProduct] = useState<Product | null>(null);
const [funnelUrl, setFunnelUrl] = useState<string | null>(null);
return (
<div>
<h1>Digital Product Publishing Wizard</h1>
{step === "upload" && (
<FileUploader
onUploadComplete={(fileId) => {
setFileRecordId(fileId);
setStep("product");
}}
/>
)}
{step === "product" && (
<ProductForm
fileRecordId={fileRecordId!}
onSave={(savedProduct) => {
setProduct(savedProduct);
setStep("publish");
}}
/>
)}
{step === "publish" && (
<PublishProduct
product={product!}
onPublished={(url) => {
setFunnelUrl(url);
setStep("complete");
}}
/>
)}
{step === "complete" && (
<div>
<h2>✅ Product Published!</h2>
<p>
Your product is now live at: <a href={funnelUrl!}>{funnelUrl}</a>
</p>
</div>
)}
</div>
);
};
Phase 4: Email Templates
4.1 Create Email Template in Database
File: valkyrai/src/main/resources/db/migration/V999__dpps_email_templates.sql
INSERT INTO email_template (id, template_name, subject, body_html, body_text, category, created_date)
VALUES (
gen_random_uuid(),
'dpps_download_ready',
'Your download is ready: {{productTitle}}',
'<html><body><h1>Thanks for your purchase!</h1><p>Your download is ready:</p><a href="{{downloadUrl}}" style="padding:10px;background:#4CAF50;color:white;text-decoration:none;">Download Now</a><p>This link expires in 15 minutes.</p></body></html>',
'Thanks for your purchase! Your download is ready: {{downloadUrl}}',
'FULFILLMENT',
NOW()
);
Phase 5: Testing & Rollout
5.1 Unit Tests
File: valkyrai/src/test/java/com/valkyrlabs/valkyrai/service/DppsOrchestrationServiceTest.java
@SpringBootTest
class DppsOrchestrationServiceTest {
@Autowired
private DppsOrchestrationService dppsService;
@MockBean
private FileRecordService fileRecordService;
@Test
void testCreateProductFromUpload() {
// Mock file record
FileRecord file = new FileRecord();
file.setId(UUID.randomUUID());
file.setFilename("ebook.pdf");
when(fileRecordService.findById(any())).thenReturn(Optional.of(file));
// Create product
DppsOrchestrationService.CreateProductRequest request = new DppsOrchestrationService.CreateProductRequest();
request.setName("Test Ebook");
request.setPrice(49.99);
Product product = dppsService.createProductFromUpload(file.getId(), request);
assertNotNull(product.getId());
assertEquals("Test Ebook", product.getName());
assertEquals(Product.StatusEnum.DRAFT, product.getStatus());
}
}
5.2 Integration Test (End-to-End)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class DppsIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "USER")
void testFullDppsFlow() throws Exception {
// 1. Upload file
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "content".getBytes());
MvcResult uploadResult = mockMvc.perform(multipart("/files/uploads/init")
.file(file))
.andExpect(status().isCreated())
.andReturn();
String fileRecordId = JsonPath.read(uploadResult.getResponse().getContentAsString(), "$.fileId");
// 2. Create product
String productJson = "{\"name\":\"Test Product\",\"salePrice\":29.99,\"contentDataId\":\"" + fileRecordId + "\"}";
MvcResult productResult = mockMvc.perform(post("/v1/Product")
.contentType(MediaType.APPLICATION_JSON)
.content(productJson))
.andExpect(status().isCreated())
.andReturn();
String productId = JsonPath.read(productResult.getResponse().getContentAsString(), "$.id");
// 3. Publish funnel
String funnelJson = "{\"productId\":\"" + productId + "\",\"brand\":\"TestBrand\",\"targetAudience\":\"Developers\"}";
mockMvc.perform(post("/api/v1/product-funnel-wizard/start")
.contentType(MediaType.APPLICATION_JSON)
.content(funnelJson))
.andExpect(status().isOk());
// 4. Simulate payment + fulfillment workflow trigger
// (would require Stripe webhook simulation)
}
}
Phase 6: Documentation & Runbooks
6.1 API Documentation (Swagger)
Auto-generated via ThorAPI. Ensure these endpoints are documented:
POST /files/uploads/init— Initiate multipart uploadPOST /files/uploads/complete— Finalize uploadPOST /v1/Product— Create productPUT /v1/Product/{id}— Update productPOST /api/v1/product-funnel-wizard/start— Start funnel generationPOST /api/v1/product-funnel-wizard/{id}/publish— Publish funnelGET /v1/download/{digitalDownloadId}— Get signed download URL
6.2 Developer Runbook
File: docs/DPPS_RUNBOOK.md
# DPPS Runbook
## Deployment Checklist
1. **Database Migrations**
```bash
cd valkyrai
mvn flyway:migrate
```
-
Rebuild ThorAPI
cd thorapi
mvn clean install
cd ../valkyrai
mvn clean install -
Environment Variables
STRIPE_SECRET_KEY— Stripe API keyMAILTRAP_API_KEY— Email sendingFILE_STORAGE_DRIVER—s3orlocal
-
Start Services
make harness-up
Troubleshooting
"Entitlement not found"
- Check DigitalDownload table for buyer's principal ID
- Verify ACL permissions:
SELECT * FROM acl_entry WHERE object_id_identity = '<productId>';
"Download limit reached"
- Increase
max_downloadson Product or DigitalDownload - Check
download_countin DigitalDownload table
"Stripe webhook failed"
- Verify webhook signature in StripeWebhookModule
- Check Stripe dashboard for event logs
---
## Phase 7: Rollout Plan (From PRD Section 22)
### Phase 1 (Dev) — Internal Dogfood
- Deploy to staging environment
- 3 test products created by internal team
- Measure: upload reliability, P95 latency (<2s)
- **Duration:** 1 week
### Phase 2 (Beta) — Limited Creators
- 25 creators invited
- Stripe test mode only
- Add revocation + refund handling
- **Duration:** 2 weeks
### Phase 3 (GA) — Production Launch
- Shopify bridge (if needed)
- Multi-file products (zip on demand)
- Coupons & bundles
- **Duration:** Ongoing
---
## Success Metrics (From PRD Section 2)
**KPIs to Track:**
- ✅ TTFP (Time-to-First-Product): ≤60s median
- ✅ Checkout conversion: ≥4.5%
- ✅ Download SLO: 99.9% success, <2s latency
- ✅ Support tickets: <1% per 100 orders
**Grafana Dashboards:**
- Upload success rate
- Product creation funnel drop-off
- Payment→fulfillment latency
- Download link redemption rate
---
## Next Steps
1. **Validate OpenAPI schema changes** (DigitalDownload, Product enhancements)
2. **Run ThorAPI codegen** (`mvn clean install` in thorapi)
3. **Implement backend services** (DppsOrchestrationService, DownloadController)
4. **Build frontend wizard** (DppsWizard.tsx)
5. **Write tests** (unit + integration)
6. **Deploy to staging** and run end-to-end test
7. **Beta launch** with 3 internal products
---
## Open Questions (From PRD Section 24)
1. **Guest checkout vs. account required?**
- **Recommendation:** Allow guest checkout, auto-create Principal on success
2. **Default entitlement expiry?**
- **Recommendation:** Perpetual (null expiry), with Product-level override
3. **License key generation?**
- **Phase 2 feature:** Add LicenseModule if needed for SaaS products
---
## Definition of Done (From PRD Section 26)
- [ ] All acceptance criteria pass in staging (real Stripe test mode)
- [ ] No mocks remain; all generated services used
- [ ] Observability dashboards deployed (Grafana)
- [ ] Security review signed off (ACL tests, link TTL, revocation)
- [ ] Runbooks updated for Support & Ops
- [ ] 3 internal products successfully published and purchased
---
**Status:** Ready for implementation
**Assignee:** ValorIDE Agent
**Priority:** High
**Estimated Effort:** 3-4 sprints (6-8 weeks)