feat(v0.501): Sprint 1 -- infrastructure foundations

- Add MinIO S3-compatible storage to docker-compose (dev, staging, prod)
- Create migrations 103-108 (waveform_url, user_folders, user_files,
  user_storage_quotas, gear_items.is_public, gear_images)
- Add Go models: UserFile, UserFolder, StorageQuota, GearImage
- Add WaveformURL to Track model, IsPublic + GearImages to GearItem model
This commit is contained in:
senke 2026-02-22 18:10:25 +01:00
parent 03d9517f2c
commit 89cc015e54
15 changed files with 293 additions and 1 deletions

View file

@ -166,6 +166,11 @@ services:
- ENABLE_CLAMAV=true
- CLAMAV_REQUIRED=true
- CLAMAV_ADDRESS=clamav:3310
- AWS_S3_ENDPOINT=http://minio:9000
- AWS_S3_BUCKET=veza-files
- AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY:?S3_ACCESS_KEY must be set}
- AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY:?S3_SECRET_KEY must be set}
- AWS_REGION=${AWS_REGION:-us-east-1}
depends_on:
postgres:
condition: service_healthy
@ -233,6 +238,41 @@ services:
timeout: 5s
retries: 3
minio:
image: minio/minio:latest
container_name: veza_minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?S3_ACCESS_KEY must be set}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?S3_SECRET_KEY must be set}
volumes:
- minio_data:/data
networks:
- veza-network
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 3
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set veza http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
mc mb --ignore-existing veza/veza-files;
exit 0;
"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?S3_ACCESS_KEY must be set}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?S3_SECRET_KEY must be set}
networks:
- veza-network
web:
build:
context: ./apps/web
@ -300,3 +340,4 @@ volumes:
redis_data:
rabbitmq_data:
hyperswitch_postgres_data:
minio_data:

View file

@ -72,6 +72,11 @@ services:
- COOKIE_HTTP_ONLY=true
- COOKIE_PATH=/
- CORS_ALLOWED_ORIGINS=${STAGING_CORS_ORIGINS:-https://staging.veza.app,https://staging-api.veza.app}
- AWS_S3_ENDPOINT=http://minio:9000
- AWS_S3_BUCKET=veza-files
- AWS_ACCESS_KEY_ID=${STAGING_S3_ACCESS_KEY:?STAGING_S3_ACCESS_KEY must be set}
- AWS_SECRET_ACCESS_KEY=${STAGING_S3_SECRET_KEY:?STAGING_S3_SECRET_KEY must be set}
- AWS_REGION=us-east-1
volumes:
- veza_logs_staging:/var/log/veza
depends_on:
@ -171,6 +176,37 @@ services:
- stream-server
- frontend
minio:
image: minio/minio:latest
container_name: veza_minio_staging
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${STAGING_S3_ACCESS_KEY:?STAGING_S3_ACCESS_KEY must be set}
MINIO_ROOT_PASSWORD: ${STAGING_S3_SECRET_KEY:?STAGING_S3_SECRET_KEY must be set}
volumes:
- minio_staging_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set veza http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
mc mb --ignore-existing veza/veza-files;
exit 0;
"
environment:
MINIO_ROOT_USER: ${STAGING_S3_ACCESS_KEY:?STAGING_S3_ACCESS_KEY must be set}
MINIO_ROOT_PASSWORD: ${STAGING_S3_SECRET_KEY:?STAGING_S3_SECRET_KEY must be set}
volumes:
postgres_staging_data:
redis_staging_data:
@ -178,4 +214,5 @@ volumes:
veza_logs_staging:
caddy_data:
caddy_config:
minio_staging_data:

View file

@ -177,6 +177,11 @@ services:
- ENABLE_CLAMAV=true
- CLAMAV_REQUIRED=false
- CLAMAV_ADDRESS=clamav:3310
- AWS_S3_ENDPOINT=http://minio:9000
- AWS_S3_BUCKET=veza-files
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-minioadmin}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-minioadmin}
- AWS_REGION=us-east-1
ports:
- "${PORT_BACKEND:-18080}:8080"
depends_on:
@ -235,6 +240,11 @@ services:
- JWT_SECRET=${JWT_SECRET:-dev-secret-key-minimum-32-characters-long}
- SECRET_KEY=${JWT_SECRET:-dev-secret-key-minimum-32-characters-long}
- PORT=3001
- AWS_S3_ENDPOINT=http://minio:9000
- AWS_S3_BUCKET=veza-files
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-minioadmin}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-minioadmin}
- AWS_REGION=us-east-1
ports:
- "${PORT_STREAM:-18082}:3001"
depends_on:
@ -250,11 +260,53 @@ services:
timeout: 5s
retries: 5
# MinIO - S3-compatible object storage (v0.501 Cloud Storage)
minio:
image: minio/minio:latest
container_name: veza_minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-minioadmin}
ports:
- "${PORT_MINIO:-19000}:9000"
- "${PORT_MINIO_CONSOLE:-19001}:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
networks:
- veza-net
# MinIO bucket initialization
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set veza http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
mc mb --ignore-existing veza/veza-files;
mc anonymous set download veza/veza-files/public;
exit 0;
"
environment:
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-minioadmin}
networks:
- veza-net
volumes:
postgres_data:
redis_data:
rabbitmq_data:
hyperswitch_postgres_data:
minio_data:
networks:
veza-net:

View file

@ -32,11 +32,13 @@ type GearItem struct {
Notes string `gorm:"type:text" json:"notes" db:"notes"`
Documents []map[string]interface{} `gorm:"type:jsonb;default:'[]'" json:"documents" db:"documents"`
MaintenanceHistory []map[string]interface{} `gorm:"type:jsonb;default:'[]'" json:"maintenanceHistory" db:"maintenance_history"`
IsPublic bool `gorm:"default:false" json:"is_public" db:"is_public"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" db:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" db:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" db:"deleted_at"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"`
GearImages []GearImage `gorm:"foreignKey:GearID;constraint:OnDelete:CASCADE" json:"gear_images,omitempty"`
}
// TableName defines the table name for GORM

View file

@ -0,0 +1,29 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type GearImage struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
GearID uuid.UUID `gorm:"type:uuid;not null" json:"gear_id"`
ImageURL string `gorm:"size:500;not null" json:"image_url"`
Position int `gorm:"not null;default:0" json:"position"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
GearItem GearItem `gorm:"foreignKey:GearID;constraint:OnDelete:CASCADE" json:"-"`
}
func (GearImage) TableName() string {
return "gear_images"
}
func (m *GearImage) BeforeCreate(tx *gorm.DB) error {
if m.ID == uuid.Nil {
m.ID = uuid.New()
}
return nil
}

View file

@ -0,0 +1,15 @@
package models
import "github.com/google/uuid"
type StorageQuota struct {
UserID uuid.UUID `gorm:"type:uuid;primaryKey" json:"user_id"`
MaxBytes int64 `gorm:"not null;default:5368709120" json:"max_bytes"`
UsedBytes int64 `gorm:"not null;default:0" json:"used_bytes"`
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"`
}
func (StorageQuota) TableName() string {
return "user_storage_quotas"
}

View file

@ -29,6 +29,7 @@ type Track struct {
Bitrate int `gorm:"default:0" json:"bitrate" db:"bitrate"` // kbps
SampleRate int `gorm:"default:0" json:"sample_rate" db:"sample_rate"` // Hz
WaveformPath string `gorm:"size:500" json:"waveform_path" db:"waveform_path"`
WaveformURL *string `gorm:"size:500" json:"waveform_url,omitempty" db:"waveform_url"`
CoverArtPath string `gorm:"size:500" json:"cover_art_path" db:"cover_art_path"`
IsPublic bool `gorm:"default:true" json:"is_public" db:"is_public"`
Status TrackStatus `gorm:"default:'uploading'" json:"status" db:"status"`

View file

@ -0,0 +1,34 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UserFile struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
FolderID *uuid.UUID `gorm:"type:uuid" json:"folder_id,omitempty"`
Filename string `gorm:"size:255;not null" json:"filename"`
S3Key string `gorm:"size:500;not null" json:"s3_key"`
SizeBytes int64 `gorm:"not null;default:0" json:"size_bytes"`
MimeType string `gorm:"size:100;not null;default:'application/octet-stream'" json:"mime_type"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"`
Folder *UserFolder `gorm:"foreignKey:FolderID;constraint:OnDelete:SET NULL" json:"-"`
}
func (UserFile) TableName() string {
return "user_files"
}
func (m *UserFile) BeforeCreate(tx *gorm.DB) error {
if m.ID == uuid.Nil {
m.ID = uuid.New()
}
return nil
}

View file

@ -0,0 +1,33 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UserFolder struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
Name string `gorm:"size:255;not null" json:"name"`
ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"`
Parent *UserFolder `gorm:"foreignKey:ParentID;constraint:OnDelete:CASCADE" json:"-"`
Children []UserFolder `gorm:"foreignKey:ParentID" json:"children,omitempty"`
Files []UserFile `gorm:"foreignKey:FolderID" json:"files,omitempty"`
}
func (UserFolder) TableName() string {
return "user_folders"
}
func (m *UserFolder) BeforeCreate(tx *gorm.DB) error {
if m.ID == uuid.Nil {
m.ID = uuid.New()
}
return nil
}

View file

@ -0,0 +1,2 @@
-- Migration 103: Add waveform_url to tracks (v0.501 S1.5)
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS waveform_url VARCHAR(500);

View file

@ -0,0 +1,12 @@
-- Migration 104: Create user_folders table (v0.501 C1.1)
CREATE TABLE IF NOT EXISTS user_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES user_folders(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_user_folders_user_id ON user_folders(user_id);
CREATE INDEX idx_user_folders_parent_id ON user_folders(parent_id);

View file

@ -0,0 +1,16 @@
-- Migration 105: Create user_files table (v0.501 C1.1)
CREATE TABLE IF NOT EXISTS user_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID REFERENCES user_folders(id) ON DELETE SET NULL,
filename VARCHAR(255) NOT NULL,
s3_key VARCHAR(500) NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
mime_type VARCHAR(100) NOT NULL DEFAULT 'application/octet-stream',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_user_files_user_id ON user_files(user_id);
CREATE INDEX idx_user_files_folder_id ON user_files(folder_id);
CREATE INDEX idx_user_files_mime_type ON user_files(mime_type);

View file

@ -0,0 +1,6 @@
-- Migration 106: Create user_storage_quotas table (v0.501 C1.1)
CREATE TABLE IF NOT EXISTS user_storage_quotas (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
max_bytes BIGINT NOT NULL DEFAULT 5368709120,
used_bytes BIGINT NOT NULL DEFAULT 0
);

View file

@ -0,0 +1,2 @@
-- Migration 107: Add is_public to gear_items (v0.501 G1.1)
ALTER TABLE gear_items ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT false;

View file

@ -0,0 +1,10 @@
-- Migration 108: Create gear_images table (v0.501 G1.2)
CREATE TABLE IF NOT EXISTS gear_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gear_id UUID NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE,
image_url VARCHAR(500) NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_gear_images_gear_id ON gear_images(gear_id);