Appearance
Asset Storage Unification
Status: Not started Goal: Migrate builder uploads from local Drive to BunnyCDN with WebP conversion, Asset DB rows, and full scope hierarchy (brand/campaign/group/creative).
Context
Two separate upload systems exist today:
- Asset library (
POST /asset-library/upload): uploads to BunnyCDN Storage, creates Asset DB row, scoped to brand/campaign - Builder uploads (
POST /assets): uploads to local Drive disk, no DB row, flatgeneral/folder with no scope
McSneaky confirmed the delivery server does nothing special beyond serving static files, so all new uploads can go to BunnyCDN. Old assets keep working from the old domain.
Out of scope
- Video uploads (separate task, involves Bunny Stream)
- URL hierarchy refactor for navigation (separate doc:
todos/url-hierarchy-refactor.md) - Migrating existing assets from Drive to BunnyCDN (old URLs keep working)
Architecture
CreativeAssetsController switches from Adonis Drive (local disk) to BunnyStorage (CDN). Accepts optional scope IDs from frontend, builds hierarchical storage paths, creates Asset DB rows. Frontend sends scope IDs from Vuex store via FileUploader. WebP conversion in AssetLibraryController already done separately.
Tech Stack: AdonisJS 5, sharp, BunnyStorage service, Japa test framework, sinon stubs, Vue 2 + Vuex
Repos:
- Backend:
/Users/nicolay/CavaiProduct/Application-Backend(branch:asset_library) - Frontend:
/Users/nicolay/CavaiProduct/Application-Frontend(branch:asset_library)
Task 1: DB Migration -- add creative scope columns
Files:
Create:
database/migrations/{timestamp}_add_creative_scope_to_assets.ts[ ] Step 1: Create migration file
typescript
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
export default class AddCreativeScopeToAssets extends BaseSchema {
protected tableName = 'assets'
public async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('creative_group_id').nullable()
table.string('creative_id').nullable()
table.index('creative_group_id')
table.index('creative_id')
})
}
public async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropIndex('creative_group_id')
table.dropIndex('creative_id')
table.dropColumn('creative_group_id')
table.dropColumn('creative_id')
})
}
}Generate the file with: node ace make:migration add_creative_scope_to_assets Then replace its contents with the above.
- [ ] Step 2: Add columns to Asset model
File: app/Models/Asset.ts
Add after the campaignId column (around line 44):
typescript
@column()
public creativeGroupId: string | null
@column()
public creativeId: string | null- [ ] Step 3: Run migration
bash
node ace migration:run- [ ] Step 4: Update AssetFactory with new columns
File: database/factories/AssetFactory.ts
Add creativeGroupId: null and creativeId: null to the factory defaults.
Task 2: Update StoreAssetValidator
Files:
Modify:
app/Validators/StoreAssetValidator.ts[ ] Step 1: Add optional creative scope fields
Add to the schema:
typescript
creative_group_id: schema.string.optional([rules.bigInt()]),
creative_id: schema.string.optional([rules.bigInt()]),Add messages:
typescript
'creative_group_id': 'Creative group ID must be a valid ID',
'creative_id': 'Creative ID must be a valid ID',Task 3: Update AssetLibraryController.store() for new columns
Files:
Modify:
app/Controllers/Http/AssetLibraryController.ts[ ] Step 1: Accept and persist creative scope fields
In store(), the validator payload already includes the new optional fields from Task 2. Update the Asset.create() call to include them:
typescript
creativeGroupId: payload.creative_group_id ?? null,
creativeId: payload.creative_id ?? null,- [ ] Step 2: Build hierarchical storage path
Replace the storage path line:
typescript
// Old: const storagePath = `${brand.workspaceId}/${brand.id}/${cuid()}.${finalExt}`
const pathSegments = [brand.workspaceId, brand.id]
if (campaign) pathSegments.push(campaign.id)
if (payload.creative_group_id) pathSegments.push(payload.creative_group_id)
if (payload.creative_id) pathSegments.push(payload.creative_id)
pathSegments.push(`${cuid()}.${finalExt}`)
const storagePath = pathSegments.join('/')Task 4: Migrate CreativeAssetsController to BunnyCDN
Files:
- Modify:
app/Controllers/Http/CreativeAssetsController.ts
This is the main change. The controller currently uploads to local Drive. We switch it to BunnyCDN, add WebP conversion, and optionally create Asset DB rows.
- [ ] Step 1: Update imports
Replace the Drive/readFile imports and add BunnyCDN + Asset imports:
typescript
import { promises as fsPromises } from 'fs'
import sharp from 'sharp'
import Asset from 'App/Models/Asset'
import Brand from 'App/Models/Brand'
import Campaign from 'App/Models/Campaign'
import BunnyStorage from 'App/Services/BunnyStorage'
import AssetMetadataExtractor from 'App/Services/AssetMetadataExtractor'
import { cuid } from '@ioc:Adonis/Core/Helpers'
import { schema, rules } from '@ioc:Adonis/Core/Validator'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'Remove the Drive and CloudFlare imports (CloudFlare is only used by getUploadUrl which we keep unchanged). Remove readFile from fs/promises (we use fsPromises from fs instead). Keep Env if getUploadUrl still needs it.
- [ ] Step 2: Rewrite the store() method
Replace the entire store() method:
typescript
public async store({ request, response, auth }: HttpContextContract) {
const ALLOWED_IMAGES = ['jpg', 'jpeg', 'png', 'gif', 'webp']
const ALLOWED_FONTS = ['woff', 'woff2', 'ttf', 'otf']
const ALLOWED_VR_FILES = ['glb', 'gltf', 'usdz']
const WEBP_CONVERTIBLE = ['jpg', 'jpeg', 'png']
const payload = await request.validate({
schema: schema.create({
resize_width: schema.number.optional(),
resize_height: schema.number.optional(),
brand_id: schema.string.optional([rules.bigInt()]),
campaign_id: schema.string.optional([rules.bigInt()]),
creative_group_id: schema.string.optional([rules.bigInt()]),
creative_id: schema.string.optional([rules.bigInt()]),
file: schema.file({
size: '10mb',
extnames: [...ALLOWED_IMAGES, ...ALLOWED_FONTS, ...ALLOWED_VR_FILES],
}),
}),
})
const uploadedFile = payload.file
const originalExt = uploadedFile.extname!
const shouldConvertToWebp = WEBP_CONVERTIBLE.includes(originalExt.toLowerCase())
// Classify file type
let fileType = ''
if (ALLOWED_IMAGES.includes(originalExt)) fileType = 'image'
else if (ALLOWED_FONTS.includes(originalExt)) fileType = 'font'
else if (ALLOWED_VR_FILES.includes(originalExt)) fileType = 'vr'
// Read and optionally convert to WebP
const originalBuffer = await fsPromises.readFile(uploadedFile.tmpPath!)
let uploadBuffer: Buffer
let finalExt: string
let fileSize: number
let width: number | undefined
let height: number | undefined
if (shouldConvertToWebp) {
const result = await sharp(originalBuffer)
.webp({ quality: 100 })
.toBuffer({ resolveWithObject: true })
uploadBuffer = result.data
finalExt = 'webp'
fileSize = result.info.size
width = result.info.width
height = result.info.height
} else {
uploadBuffer = originalBuffer
finalExt = originalExt
fileSize = uploadedFile.size
}
// Build storage path with hierarchy when scope IDs are provided
const brand = payload.brand_id ? await Brand.findOrFail(payload.brand_id) : null
const campaign = payload.campaign_id ? await Campaign.findOrFail(payload.campaign_id) : null
const pathSegments: string[] = []
if (brand) {
pathSegments.push(brand.workspaceId, brand.id)
if (campaign) pathSegments.push(campaign.id)
if (payload.creative_group_id) pathSegments.push(payload.creative_group_id)
if (payload.creative_id) pathSegments.push(payload.creative_id)
} else {
pathSegments.push('general')
}
pathSegments.push(`${cuid()}.${finalExt}`)
const storagePath = pathSegments.join('/')
// Upload to BunnyCDN
await BunnyStorage.upload(storagePath, uploadBuffer)
const cdnUrl = BunnyStorage.getPublicUrl(storagePath)
// Create Asset DB row when brand context is available
if (brand) {
try {
await Asset.create({
name: uploadedFile.clientName.slice(0, 255),
type: fileType,
brandId: brand.id,
campaignId: campaign?.id ?? null,
creativeGroupId: payload.creative_group_id ?? null,
creativeId: payload.creative_id ?? null,
workspaceId: brand.workspaceId,
storagePath,
cdnUrl,
thumbnailUrl: fileType === 'image' ? cdnUrl : null,
fileSize,
mimeType: shouldConvertToWebp ? 'image/webp' : (uploadedFile.headers['content-type'] || null),
metadata: width && height ? { width, height } : null,
uploadedBy: auth.user?.id ?? null,
})
} catch (err) {
// Clean up orphaned Bunny file
try { await BunnyStorage.delete(storagePath) } catch {}
throw err
}
}
return response.created({
data: {
url: cdnUrl,
clientName: uploadedFile.clientName,
filename: storagePath,
type: fileType,
size: fileSize,
extname: finalExt,
...(width && height ? { width, height } : {}),
},
})
}- [ ] Step 3: Clean up unused imports
Remove Drive from imports if no other method uses it. Keep Env, CloudFlare, Bunny (video), CreativeGroupCreative if other methods in the controller still reference them.
Task 5: Backend tests for CreativeAssetsController
Files:
Create:
tests/functional/creative_assets/upload.spec.ts[ ] Step 1: Write test file
typescript
import { test } from '@japa/runner'
import sinon from 'sinon'
import Asset from 'App/Models/Asset'
import { file } from '@ioc:Adonis/Core/Helpers'
import Database from '@ioc:Adonis/Lucid/Database'
import BunnyStorage from 'App/Services/BunnyStorage'
import { BrandFactory, CampaignFactory, WorkspaceFactory } from 'Database/factories'
import { accessControlTestLoginUserData } from 'TestHelpers/index'
import { RolesEnum } from 'Database/stubs/roles'
test.group('Creative Assets | upload', (group) => {
let uploadStub: sinon.SinonStub
let deleteStub: sinon.SinonStub
group.each.setup(async () => {
await Database.beginGlobalTransaction()
uploadStub = sinon.stub(BunnyStorage, 'upload').resolves()
deleteStub = sinon.stub(BunnyStorage, 'delete').resolves()
return () => {
uploadStub.restore()
deleteStub.restore()
return Database.rollbackGlobalTransaction()
}
})
test('uploads image and converts to webp', async ({ client, assert }) => {
const { loginUser } = await accessControlTestLoginUserData(RolesEnum.GlobalAdmin, false)
const fakePng = await file.generatePng('10kb')
const pngBuffer = Buffer.isBuffer(fakePng.contents)
? fakePng.contents
: require('fs').readFileSync(fakePng.contents as any)
const response = await client
.post('/v1/assets')
.loginAs(loginUser)
.file('file', pngBuffer, { filename: 'photo.png' })
response.assertStatus(201)
const body = response.body()
assert.equal(body.data.extname, 'webp')
assert.equal(body.data.type, 'image')
assert.isTrue(body.data.url.endsWith('.webp'))
})
test('passes through gif without webp conversion', async ({ client, assert }) => {
const { loginUser } = await accessControlTestLoginUserData(RolesEnum.GlobalAdmin, false)
// Create a minimal valid GIF buffer (GIF89a header)
const gifBuffer = Buffer.from(
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
'base64'
)
const response = await client
.post('/v1/assets')
.loginAs(loginUser)
.file('file', gifBuffer, { filename: 'animation.gif' })
response.assertStatus(201)
const body = response.body()
assert.equal(body.data.extname, 'gif')
})
test('creates Asset DB row when brand_id is provided', async ({ client, assert }) => {
const { loginUser } = await accessControlTestLoginUserData(RolesEnum.GlobalAdmin, false)
const workspace = await WorkspaceFactory.create()
const brand = await BrandFactory.merge({ workspaceId: workspace.id }).create()
const fakePng = await file.generatePng('10kb')
const pngBuffer = Buffer.isBuffer(fakePng.contents)
? fakePng.contents
: require('fs').readFileSync(fakePng.contents as any)
const response = await client
.post('/v1/assets')
.loginAs(loginUser)
.field('brand_id', brand.id)
.file('file', pngBuffer, { filename: 'logo.png' })
response.assertStatus(201)
const count = await Asset.query().where('brandId', brand.id).count('* as total')
assert.equal(Number((count[0].$extras as any).total), 1)
})
test('does not create Asset DB row without brand_id', async ({ client, assert }) => {
const { loginUser } = await accessControlTestLoginUserData(RolesEnum.GlobalAdmin, false)
const fakePng = await file.generatePng('10kb')
const pngBuffer = Buffer.isBuffer(fakePng.contents)
? fakePng.contents
: require('fs').readFileSync(fakePng.contents as any)
const beforeCount = await Asset.query().count('* as total')
const before = Number((beforeCount[0].$extras as any).total)
await client
.post('/v1/assets')
.loginAs(loginUser)
.file('file', pngBuffer, { filename: 'quick.png' })
const afterCount = await Asset.query().count('* as total')
const after = Number((afterCount[0].$extras as any).total)
assert.equal(before, after)
})
test('builds hierarchical storage path with full scope', async ({ client, assert }) => {
const { loginUser } = await accessControlTestLoginUserData(RolesEnum.GlobalAdmin, false)
const workspace = await WorkspaceFactory.create()
const brand = await BrandFactory.merge({ workspaceId: workspace.id }).create()
const campaign = await CampaignFactory.merge({ brandId: brand.id }).create()
const fakePng = await file.generatePng('10kb')
const pngBuffer = Buffer.isBuffer(fakePng.contents)
? fakePng.contents
: require('fs').readFileSync(fakePng.contents as any)
const response = await client
.post('/v1/assets')
.loginAs(loginUser)
.field('brand_id', brand.id)
.field('campaign_id', campaign.id)
.field('creative_group_id', '999')
.field('creative_id', '1234')
.file('file', pngBuffer, { filename: 'bg.png' })
response.assertStatus(201)
// Verify BunnyStorage.upload was called with hierarchical path
const callArgs = uploadStub.firstCall.args
const storagePath = callArgs[0] as string
assert.isTrue(storagePath.startsWith(`${workspace.id}/${brand.id}/${campaign.id}/999/1234/`))
assert.isTrue(storagePath.endsWith('.webp'))
})
test('uses general/ prefix when no brand_id provided', async ({ client, assert }) => {
const { loginUser } = await accessControlTestLoginUserData(RolesEnum.GlobalAdmin, false)
const fakePng = await file.generatePng('10kb')
const pngBuffer = Buffer.isBuffer(fakePng.contents)
? fakePng.contents
: require('fs').readFileSync(fakePng.contents as any)
await client
.post('/v1/assets')
.loginAs(loginUser)
.file('file', pngBuffer, { filename: 'quick.png' })
const callArgs = uploadStub.firstCall.args
const storagePath = callArgs[0] as string
assert.isTrue(storagePath.startsWith('general/'))
})
})- [ ] Step 2: Run tests
bash
node ace test --files="tests/functional/creative_assets/upload.spec.ts"- [ ] Step 3: Also run existing asset library tests to verify nothing broke
bash
node ace test --files="tests/functional/asset_library/*"Task 6: Frontend -- FileUploader sends scope IDs
Files:
Modify:
src/components/common/FileUploader.vueModify:
src/services/chatbotService.js[ ] Step 1: Add scopeIds prop to FileUploader
In the props section of FileUploader.vue, add:
typescript
scopeIds: {
type: Object,
default: null,
},- [ ] Step 2: Send scope IDs in uploadFile()
In the uploadFile() method, after the resize fields, add:
typescript
if (this.scopeIds) {
if (this.scopeIds.brandId) formData.append('brand_id', this.scopeIds.brandId)
if (this.scopeIds.campaignId) formData.append('campaign_id', this.scopeIds.campaignId)
if (this.scopeIds.creativeGroupId) formData.append('creative_group_id', this.scopeIds.creativeGroupId)
if (this.scopeIds.creativeId) formData.append('creative_id', this.scopeIds.creativeId)
}- [ ] Step 3: Pass scopeIds from builder config components
The components that use FileUploader in the builder all have access to the Vuex store. The scope IDs come from the creative's route params.
In each builder component that uses <FileUploader>, add :scope-ids="assetScopeIds" and provide the computed:
typescript
computed: {
assetScopeIds() {
const props = this.$store.state.builder.creativeData?.creativeSettings?.creativeProperties
if (!props) return null
return {
brandId: props.brandId,
campaignId: props.campaignId,
creativeGroupId: props.creativeGroupId,
creativeId: this.$store.state.builder.creativeData?.id,
}
},
}Files to update (search for <FileUploader in builder context):
src/pages/Chatbots/components/BuilderVisuals/Configuration/components/FontSettings/TypefaceSection.vuesrc/pages/Chatbots/components/BuilderVisuals/Configuration/configs/ArConfiguration.vuesrc/pages/Chatbots/components/BuilderVisuals/Configuration/components/VideoSection.vuesrc/pages/Chatbots/components/CavaiFlow/operators/ChangeVideoOp.vuesrc/pages/Chatbots/components/CavaiFlow/operators/LibraryScriptOp.vue
Also check for direct chatbotService.uploadAsset() calls outside FileUploader (e.g., in flow operators that upload images directly).
Task 7: Update AssetResource serializer
Files:
Modify:
app/Resources/Asset.ts(if it exists)[ ] Step 1: Include new columns in serialized output
Check if AssetResource explicitly lists fields. If so, add creative_group_id and creative_id to the serialization. If it uses asset.serialize() directly, the new columns will be included automatically via the model.
Task 8: Verify and commit
- [ ] Step 1: Run full backend test suite
bash
npm test- [ ] Step 2: Start frontend dev server and test
Upload an image in the builder. Verify:
Image appears in asset library
URL points to BunnyCDN (not delivery server)
Storage path in Bunny has full hierarchy
WebP conversion works (upload a jpg, check the URL ends in .webp)
Fonts upload correctly (no webp conversion)
[ ] Step 3: Test backwards compatibility
If any code still calls POST /assets without brand_id, it should still work (uploads to general/ on Bunny, no DB row).
- [ ] Step 4: Commit backend and frontend separately
bash
# Backend
git add -A
git commit -m "Migrate builder uploads to BunnyCDN with WebP conversion and Asset DB rows"
# Frontend
git add -A
git commit -m "Send scope IDs with asset uploads for asset library tracking"