feat: add database schema and SQLx migrations
- 5 reversible migrations: extensions/enums, tables, indexes, views, config seed - Tables: event, user, session, upload, hashtag, upload_hashtag, comment, comment_hashtag, like, export_job, config - Views: v_feed (uploads with like/comment counts), v_hashtag_counts - Indexes optimised for feed queries, session lookup, hashtag filtering - Config table seeded with default rate limits and quotas - db.rs module: PgPool creation with auto-migration on startup - docker-compose.override.yml: expose db port 5432 for local dev - Fix crate names: async_zip, tower_governor (underscore, not hyphen) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
4216
backend/Cargo.lock
generated
Normal file
4216
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,12 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.8", features = ["multipart"] }
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "macros"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "macros", "migrate"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace", "cors", "compression-full", "fs"] }
|
tower-http = { version = "0.6", features = ["trace", "cors", "compression-full", "fs"] }
|
||||||
tower-governor = "0.4"
|
tower_governor = "0.4"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
@@ -23,7 +23,7 @@ dotenvy = "0.15"
|
|||||||
sysinfo = "0.32"
|
sysinfo = "0.32"
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
oxipng = "9"
|
oxipng = "9"
|
||||||
async-zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
||||||
minijinja = "2"
|
minijinja = "2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
4
backend/migrations/001_extensions_and_enums.down.sql
Normal file
4
backend/migrations/001_extensions_and_enums.down.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TYPE IF EXISTS export_status;
|
||||||
|
DROP TYPE IF EXISTS export_type;
|
||||||
|
DROP TYPE IF EXISTS user_role;
|
||||||
|
-- pgcrypto is left in place (shared extension, safe to keep)
|
||||||
7
backend/migrations/001_extensions_and_enums.up.sql
Normal file
7
backend/migrations/001_extensions_and_enums.up.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Enable pgcrypto for gen_random_uuid()
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- Custom enum types
|
||||||
|
CREATE TYPE user_role AS ENUM ('guest', 'host', 'admin');
|
||||||
|
CREATE TYPE export_type AS ENUM ('zip', 'html');
|
||||||
|
CREATE TYPE export_status AS ENUM ('pending', 'running', 'done', 'failed');
|
||||||
11
backend/migrations/002_tables.down.sql
Normal file
11
backend/migrations/002_tables.down.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
DROP TABLE IF EXISTS config;
|
||||||
|
DROP TABLE IF EXISTS export_job;
|
||||||
|
DROP TABLE IF EXISTS "like";
|
||||||
|
DROP TABLE IF EXISTS comment_hashtag;
|
||||||
|
DROP TABLE IF EXISTS comment;
|
||||||
|
DROP TABLE IF EXISTS upload_hashtag;
|
||||||
|
DROP TABLE IF EXISTS hashtag;
|
||||||
|
DROP TABLE IF EXISTS upload;
|
||||||
|
DROP TABLE IF EXISTS session;
|
||||||
|
DROP TABLE IF EXISTS "user";
|
||||||
|
DROP TABLE IF EXISTS event;
|
||||||
112
backend/migrations/002_tables.up.sql
Normal file
112
backend/migrations/002_tables.up.sql
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
-- event
|
||||||
|
CREATE TABLE event (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
cover_image_path TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
uploads_locked_at TIMESTAMPTZ,
|
||||||
|
export_released_at TIMESTAMPTZ,
|
||||||
|
export_zip_ready BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
export_html_ready BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- user
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
role user_role NOT NULL DEFAULT 'guest',
|
||||||
|
is_banned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
uploads_hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
recovery_pin_hash TEXT NOT NULL,
|
||||||
|
total_upload_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- session
|
||||||
|
CREATE TABLE session (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- upload
|
||||||
|
CREATE TABLE upload (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES "user"(id),
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
preview_path TEXT,
|
||||||
|
thumbnail_path TEXT,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
original_size_bytes BIGINT NOT NULL,
|
||||||
|
caption TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- hashtag
|
||||||
|
CREATE TABLE hashtag (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (event_id, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- upload_hashtag (junction)
|
||||||
|
CREATE TABLE upload_hashtag (
|
||||||
|
upload_id UUID NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
|
||||||
|
hashtag_id UUID NOT NULL REFERENCES hashtag(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (upload_id, hashtag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- comment
|
||||||
|
CREATE TABLE comment (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
upload_id UUID NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES "user"(id),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- comment_hashtag (junction)
|
||||||
|
CREATE TABLE comment_hashtag (
|
||||||
|
comment_id UUID NOT NULL REFERENCES comment(id) ON DELETE CASCADE,
|
||||||
|
hashtag_id UUID NOT NULL REFERENCES hashtag(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (comment_id, hashtag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- like
|
||||||
|
CREATE TABLE "like" (
|
||||||
|
upload_id UUID NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (upload_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- export_job
|
||||||
|
CREATE TABLE export_job (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
|
||||||
|
type export_type NOT NULL,
|
||||||
|
status export_status NOT NULL DEFAULT 'pending',
|
||||||
|
progress_pct SMALLINT NOT NULL DEFAULT 0 CHECK (progress_pct BETWEEN 0 AND 100),
|
||||||
|
file_path TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (event_id, type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- config (admin-configurable runtime settings)
|
||||||
|
CREATE TABLE config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
10
backend/migrations/003_indexes.down.sql
Normal file
10
backend/migrations/003_indexes.down.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_export_job_event_type;
|
||||||
|
DROP INDEX IF EXISTS idx_user_event_name;
|
||||||
|
DROP INDEX IF EXISTS idx_session_expires;
|
||||||
|
DROP INDEX IF EXISTS idx_session_token_hash;
|
||||||
|
DROP INDEX IF EXISTS idx_hashtag_event_tag;
|
||||||
|
DROP INDEX IF EXISTS idx_upload_hashtag_hashtag;
|
||||||
|
DROP INDEX IF EXISTS idx_like_upload;
|
||||||
|
DROP INDEX IF EXISTS idx_comment_upload;
|
||||||
|
DROP INDEX IF EXISTS idx_upload_user;
|
||||||
|
DROP INDEX IF EXISTS idx_upload_event_created;
|
||||||
41
backend/migrations/003_indexes.up.sql
Normal file
41
backend/migrations/003_indexes.up.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- Feed main query (covers >90% of all DB requests)
|
||||||
|
CREATE INDEX idx_upload_event_created
|
||||||
|
ON upload(event_id, created_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- A user's own uploads (quota check, "My Account")
|
||||||
|
CREATE INDEX idx_upload_user
|
||||||
|
ON upload(user_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Comments per upload
|
||||||
|
CREATE INDEX idx_comment_upload
|
||||||
|
ON comment(upload_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Like count per upload
|
||||||
|
CREATE INDEX idx_like_upload
|
||||||
|
ON "like"(upload_id);
|
||||||
|
|
||||||
|
-- Hashtag filtering
|
||||||
|
CREATE INDEX idx_upload_hashtag_hashtag
|
||||||
|
ON upload_hashtag(hashtag_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_hashtag_event_tag
|
||||||
|
ON hashtag(event_id, tag);
|
||||||
|
|
||||||
|
-- Session lookup (runs on every authenticated API request)
|
||||||
|
CREATE UNIQUE INDEX idx_session_token_hash
|
||||||
|
ON session(token_hash);
|
||||||
|
|
||||||
|
-- Expired session cleanup
|
||||||
|
CREATE INDEX idx_session_expires
|
||||||
|
ON session(expires_at);
|
||||||
|
|
||||||
|
-- User lookup for recovery flow
|
||||||
|
CREATE INDEX idx_user_event_name
|
||||||
|
ON "user"(event_id, display_name);
|
||||||
|
|
||||||
|
-- Export job status
|
||||||
|
CREATE UNIQUE INDEX idx_export_job_event_type
|
||||||
|
ON export_job(event_id, type);
|
||||||
2
backend/migrations/004_views.down.sql
Normal file
2
backend/migrations/004_views.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP VIEW IF EXISTS v_hashtag_counts;
|
||||||
|
DROP VIEW IF EXISTS v_feed;
|
||||||
35
backend/migrations/004_views.up.sql
Normal file
35
backend/migrations/004_views.up.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- v_feed: uploads with uploader name, like count, and comment count
|
||||||
|
CREATE VIEW v_feed AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.event_id,
|
||||||
|
u.user_id,
|
||||||
|
usr.display_name AS uploader_name,
|
||||||
|
usr.is_banned,
|
||||||
|
usr.uploads_hidden,
|
||||||
|
u.preview_path,
|
||||||
|
u.thumbnail_path,
|
||||||
|
u.mime_type,
|
||||||
|
u.caption,
|
||||||
|
u.created_at,
|
||||||
|
COUNT(DISTINCT l.user_id) AS like_count,
|
||||||
|
COUNT(DISTINCT c.id) AS comment_count
|
||||||
|
FROM upload u
|
||||||
|
JOIN "user" usr ON u.user_id = usr.id
|
||||||
|
LEFT JOIN "like" l ON l.upload_id = u.id
|
||||||
|
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE
|
||||||
|
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
|
||||||
|
|
||||||
|
-- v_hashtag_counts: most-used hashtags for an event (for filter chips)
|
||||||
|
CREATE VIEW v_hashtag_counts AS
|
||||||
|
SELECT
|
||||||
|
h.event_id,
|
||||||
|
h.tag,
|
||||||
|
COUNT(uh.upload_id) AS upload_count
|
||||||
|
FROM hashtag h
|
||||||
|
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
|
||||||
|
JOIN upload u ON u.id = uh.upload_id AND u.deleted_at IS NULL
|
||||||
|
GROUP BY h.event_id, h.id, h.tag
|
||||||
|
ORDER BY upload_count DESC;
|
||||||
10
backend/migrations/005_seed_config.down.sql
Normal file
10
backend/migrations/005_seed_config.down.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
DELETE FROM config WHERE key IN (
|
||||||
|
'max_image_size_mb',
|
||||||
|
'max_video_size_mb',
|
||||||
|
'upload_rate_per_hour',
|
||||||
|
'feed_rate_per_min',
|
||||||
|
'export_rate_per_day',
|
||||||
|
'quota_tolerance',
|
||||||
|
'estimated_guest_count',
|
||||||
|
'compression_concurrency'
|
||||||
|
);
|
||||||
12
backend/migrations/005_seed_config.up.sql
Normal file
12
backend/migrations/005_seed_config.up.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Seed default config values from .env defaults.
|
||||||
|
-- ON CONFLICT DO NOTHING preserves any existing admin overrides.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('max_image_size_mb', '20'),
|
||||||
|
('max_video_size_mb', '500'),
|
||||||
|
('upload_rate_per_hour', '10'),
|
||||||
|
('feed_rate_per_min', '60'),
|
||||||
|
('export_rate_per_day', '3'),
|
||||||
|
('quota_tolerance', '0.75'),
|
||||||
|
('estimated_guest_count', '100'),
|
||||||
|
('compression_concurrency', '2')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
19
backend/src/db.rs
Normal file
19
backend/src/db.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.connect(database_url)
|
||||||
|
.await
|
||||||
|
.context("failed to connect to database")?;
|
||||||
|
|
||||||
|
sqlx::migrate!()
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.context("failed to run database migrations")?;
|
||||||
|
|
||||||
|
tracing::info!("database connected and migrations applied");
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
mod db;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
@@ -12,10 +14,14 @@ async fn main() -> Result<()> {
|
|||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
let database_url =
|
||||||
|
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let port: u16 = std::env::var("APP_PORT")
|
let port: u16 = std::env::var("APP_PORT")
|
||||||
.unwrap_or_else(|_| "3000".to_string())
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
.parse()?;
|
.parse()?;
|
||||||
|
|
||||||
|
let _pool = db::create_pool(&database_url).await?;
|
||||||
|
|
||||||
let router = axum::Router::new()
|
let router = axum::Router::new()
|
||||||
.route("/health", axum::routing::get(|| async { "ok" }));
|
.route("/health", axum::routing::get(|| async { "ok" }));
|
||||||
|
|
||||||
|
|||||||
4
docker-compose.override.yml
Normal file
4
docker-compose.override.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
Reference in New Issue
Block a user