diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 602202d98..00f65a5cb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 18087db1c..faa8483d7 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index fe4c8b715..3b2bba82f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/veza-backend-api/internal/models/gear.go b/veza-backend-api/internal/models/gear.go index d01948739..8f83b0fa6 100644 --- a/veza-backend-api/internal/models/gear.go +++ b/veza-backend-api/internal/models/gear.go @@ -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 diff --git a/veza-backend-api/internal/models/gear_image.go b/veza-backend-api/internal/models/gear_image.go new file mode 100644 index 000000000..039e4950d --- /dev/null +++ b/veza-backend-api/internal/models/gear_image.go @@ -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 +} diff --git a/veza-backend-api/internal/models/storage_quota.go b/veza-backend-api/internal/models/storage_quota.go new file mode 100644 index 000000000..5314f41b0 --- /dev/null +++ b/veza-backend-api/internal/models/storage_quota.go @@ -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" +} diff --git a/veza-backend-api/internal/models/track.go b/veza-backend-api/internal/models/track.go index 2346c283f..9902581ae 100644 --- a/veza-backend-api/internal/models/track.go +++ b/veza-backend-api/internal/models/track.go @@ -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"` diff --git a/veza-backend-api/internal/models/user_file.go b/veza-backend-api/internal/models/user_file.go new file mode 100644 index 000000000..54bdc1f26 --- /dev/null +++ b/veza-backend-api/internal/models/user_file.go @@ -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 +} diff --git a/veza-backend-api/internal/models/user_folder.go b/veza-backend-api/internal/models/user_folder.go new file mode 100644 index 000000000..7fbcc3d8e --- /dev/null +++ b/veza-backend-api/internal/models/user_folder.go @@ -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 +} diff --git a/veza-backend-api/migrations/103_tracks_waveform.sql b/veza-backend-api/migrations/103_tracks_waveform.sql new file mode 100644 index 000000000..ad20402ea --- /dev/null +++ b/veza-backend-api/migrations/103_tracks_waveform.sql @@ -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); diff --git a/veza-backend-api/migrations/104_user_folders.sql b/veza-backend-api/migrations/104_user_folders.sql new file mode 100644 index 000000000..adad5106d --- /dev/null +++ b/veza-backend-api/migrations/104_user_folders.sql @@ -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); diff --git a/veza-backend-api/migrations/105_user_files.sql b/veza-backend-api/migrations/105_user_files.sql new file mode 100644 index 000000000..a547a47c4 --- /dev/null +++ b/veza-backend-api/migrations/105_user_files.sql @@ -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); diff --git a/veza-backend-api/migrations/106_user_storage_quotas.sql b/veza-backend-api/migrations/106_user_storage_quotas.sql new file mode 100644 index 000000000..f0da48054 --- /dev/null +++ b/veza-backend-api/migrations/106_user_storage_quotas.sql @@ -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 +); diff --git a/veza-backend-api/migrations/107_gear_is_public.sql b/veza-backend-api/migrations/107_gear_is_public.sql new file mode 100644 index 000000000..f3b06d25f --- /dev/null +++ b/veza-backend-api/migrations/107_gear_is_public.sql @@ -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; diff --git a/veza-backend-api/migrations/108_gear_images.sql b/veza-backend-api/migrations/108_gear_images.sql new file mode 100644 index 000000000..d1aaeeb97 --- /dev/null +++ b/veza-backend-api/migrations/108_gear_images.sql @@ -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);