Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ homepage = "https://waveflow.app"
# build is reproducible — bump the rev in tree when picking up a new
# core release. `default-features = false` because core's
# default-feature set is empty; we only want the `postgres` feature.
waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "062c5509752f0f816dca272454ac2f2d4e84bd79", default-features = false, features = ["postgres"] }
waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "25b9ada6c2d5404cd40343a8fda44a92bfb46968", default-features = false, features = ["postgres"] }

# Database. `runtime-tokio` matches our `#[tokio::main]` runtime;
# `postgres` is the driver; `macros` enables `query_as!`; `migrate`
Expand Down
70 changes: 70 additions & 0 deletions migrations/20260530000004_playlist.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- Playlist table — multi-tenant counterpart of the desktop's `playlist`
-- row (see `src-tauri/migrations/profile/20260411120000_initial.sql`
-- in the WaveFlow repo). A playlist belongs directly to a profile —
-- different from `track`, which sits one tier deeper under `library`.
--
-- 1.b.5c ships custom playlists only. Smart playlists (`is_smart = 1`
-- with `smart_rules` JSON) and the playlist_track join still live
-- exclusively on the desktop until later phases port the smart-playlist
-- engine and the tracks-in-playlist routes. The columns are present so
-- the wire shape stays in lockstep with the desktop's `Playlist` DTO;
-- the server-side repo just hardcodes `is_smart = 0`, `smart_rules =
-- NULL` on inserts.
--
-- ON DELETE CASCADE on `profile_id` so a profile delete fan-outs to
-- its playlists. Every playlist must belong to a profile — an
-- orphaned playlist would violate the tenancy chain that
-- `PostgresPlaylistRepository` enforces in `waveflow-core`.

CREATE TABLE playlist (
id BIGSERIAL PRIMARY KEY,
profile_id BIGINT NOT NULL
REFERENCES profile(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,

-- Brand-defined design-system tokens; defaults mirror the desktop
-- (`DEFAULT 'violet'` / `DEFAULT 'music'`). The repo writes the
-- columns explicitly so the server stays authoritative when the
-- client omits them.
color_id TEXT NOT NULL DEFAULT 'violet',
icon_id TEXT NOT NULL DEFAULT 'music',

-- Smart-playlist discriminant + rule payload. BIGINT (not
-- BOOLEAN / SMALLINT) so the column round-trips into
-- `Playlist.is_smart: i64` from waveflow-core without a
-- narrowing decode error — same lesson as `track.rating` in
-- 1.b.5b. Today the server only writes 0 / NULL; the columns
-- exist for forward parity with the desktop schema.
is_smart BIGINT NOT NULL DEFAULT 0,
smart_rules TEXT,

-- Cover management. `cover_hash` references the shared
-- `metadata_artwork/<blake3>.jpg` blob (the cache table itself
-- hasn't been ported to the server yet; the column is here for
-- forward parity). `cover_is_auto = 1` means the auto-regen
-- pipeline owns the slot — `0` is reserved for the case where
-- the user uploaded their own image and the pipeline should
-- leave the row alone, matching the desktop convention. Default
-- mirrors the desktop's `DEFAULT 1`.
cover_hash TEXT,
cover_is_auto BIGINT NOT NULL DEFAULT 1,

-- Drag-and-drop sidebar order. `0` is fine as a default — the
-- desktop already lives with collisions on this column (it
-- orders by `position ASC, updated_at DESC` so ties resolve on
-- recency), and the server's `list_for_profile` follows the
-- same order.
position BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);

-- The per-profile list query orders by `(position ASC, updated_at
-- DESC)` filtered on `profile_id`; the composite index keeps the
-- per-tenant lookup flat as the table grows across profiles. It also
-- serves the equality filter on its leading column for the ON DELETE
-- CASCADE fan-out from `profile`, so a `profile_id`-only index would
-- be pure write amplification.
CREATE INDEX playlist_profile_position_idx
ON playlist (profile_id, position ASC, updated_at DESC);
23 changes: 17 additions & 6 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
//! readiness, `users.rs` mints dev-shim users, `profiles.rs` covers
//! tenant-scoped profile CRUD, `libraries.rs` covers tenant-scoped
//! library CRUD nested under a profile, `tracks.rs` covers
//! tenant-scoped track CRUD nested under a library. Future modules
//! will cover `playlists`, `auth`, `sync`, `stream` (per RFC-001
//! §6 / §7).
//! tenant-scoped track CRUD nested under a library, `playlists.rs`
//! covers tenant-scoped playlist CRUD nested under a profile (same
//! depth as library). Future modules will cover `auth`, `sync`,
//! `stream` (per RFC-001 §6 / §7).
//!
//! Versioning policy: every resource module mounts under `/api/v1/`
//! (except `/health` and `/ready`, which are unversioned by convention
Expand All @@ -28,6 +29,7 @@ use crate::{middleware as auth_middleware, AppState, Config};

mod health;
mod libraries;
mod playlists;
mod profiles;
mod ready;
mod tracks;
Expand All @@ -38,10 +40,11 @@ mod users;
/// their `#[utoipa::path]` declarations to the merged OpenAPI spec.
///
/// `/api/v1/users`, `/api/v1/profiles/*`,
/// `/api/v1/profiles/{profile_id}/libraries/*` and
/// `/api/v1/profiles/{profile_id}/libraries/*`,
/// `/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/*`
/// ride behind [`reject_dev_auth_disabled`] when
/// `config.dev_auth_enabled` is false (the production default).
/// and `/api/v1/profiles/{profile_id}/playlists/*` ride behind
/// [`reject_dev_auth_disabled`] when `config.dev_auth_enabled` is
/// false (the production default).
/// Without the gate a forged `X-User-Id` header on a publicly-exposed
/// instance would walk straight into another tenant's data — Phase
/// 1.d retires both the flag and the shim together when Better Auth
Expand Down Expand Up @@ -72,6 +75,13 @@ pub fn router(state: AppState, config: &Config) -> OpenApiRouter {
tracks::router(state.clone()).layer(middleware::from_fn(reject_dev_auth_disabled))
};

let playlists_router = if config.dev_auth_enabled {
playlists::router(state.clone())
.layer(middleware::from_fn(auth_middleware::require_user_id))
} else {
playlists::router(state.clone()).layer(middleware::from_fn(reject_dev_auth_disabled))
};

OpenApiRouter::new()
// Probes — no auth, no gate.
.merge(health::router())
Expand All @@ -80,6 +90,7 @@ pub fn router(state: AppState, config: &Config) -> OpenApiRouter {
.merge(profiles_router)
.merge(libraries_router)
.merge(tracks_router)
.merge(playlists_router)
}

/// Reject every request with **503 Service Unavailable**. Mounted on
Expand Down
Loading
Loading