diff --git a/.env.example b/.env.example index 26d3cd3f..2c16e256 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ S3_BUCKET=programmerbar REDIS_URL=redis://localhost:6379 +WEB_REDIRECT_URL="http://localhost:5173" + ADMIN_KEY=foobar IS_DEV="true" + +AUTH_SECRET="foobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbaz" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26dc0b51..efb0a494 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,7 +80,7 @@ jobs: run: cargo install sqlx-cli --no-default-features --features postgres - name: 🗄️ Run database migrations - run: pnpm --filter=api run-migrations --database-url postgres://postgres:postgres@localhost:5432/programmerbar + run: pnpm --filter=api db:migrate --database-url postgres://postgres:postgres@localhost:5432/programmerbar - name: 📝 Prepare SQLx offline cache run: | diff --git a/CLAUDE.md b/CLAUDE.md index feeb1987..2e8cfc06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Architecture ### Monorepo Structure + - `apps/www/` - Main SvelteKit application (primary codebase) - `apps/cms/` - Sanity headless CMS for content management - `apps/api/` - Rust API backend with Axum web framework - `internal/emails/` - React Email templates for notifications ### Technology Stack + - **Frontend**: SvelteKit 2.28 with Svelte 5, Tailwind CSS v4 - **Backend**: Rust with Axum web framework, Tokio async runtime - **Database**: Cloudflare D1 (SQLite) with Drizzle ORM 0.44 @@ -24,6 +26,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **CMS**: Sanity v4 for product/content management ### Service Architecture + The application uses a service layer pattern with dependency injection via SvelteKit `locals`. All services are available in `app.d.ts`: - `userService` - User CRUD, role management (board/normal) @@ -40,9 +43,10 @@ The application uses a service layer pattern with dependency injection via Svelt ## Development Commands ### Setup + ```bash # Copy environment variables -cp apps/www/.dev.vars.example apps/www/.dev.vars +cp apps/www/.env.example apps/www/.env # Fill in Resend API key, Feide OAuth credentials # Install dependencies (Node.js and Rust) @@ -56,9 +60,10 @@ pnpm dev ``` ### Common Tasks + ```bash # Development -pnpm dev # Start all services (website: :5173, CMS: :3333, API: :3000) +pnpm dev # Start all services (website: :5173, CMS: :3333, API: :8000) # Rust API Development cd apps/api @@ -88,6 +93,7 @@ pnpm preview # Local production preview ``` ### Database Management + ```bash # Create user invitation (required for first login) pnpm dlx tsx ./apps/www/scripts/add-invitation.ts "user@email.com" @@ -99,16 +105,18 @@ pnpm dlx tsx ./apps/www/scripts/users.ts ## Database Schema (Drizzle ORM) ### Core Entities + - **users** - Authentication via Feide, roles (board/normal), beer credits - **sessions** - Lucia auth sessions - **events** - Event management with dates -- **shifts** - Volunteer shifts with user assignments and event relationships +- **shifts** - Volunteer shifts with user assignments and event relationships - **groups** - User groups and memberships - **invitations** - User invitation system with expiration - **notifications** - User notification delivery - **claimedCredits** - Beer credit transaction tracking ### Key Relationships + - Users → Shifts (many-to-many via assignments) - Events → Shifts (one-to-many) - Users → Groups (many-to-many via memberships) @@ -117,11 +125,13 @@ pnpm dlx tsx ./apps/www/scripts/users.ts ## Authentication & Authorization ### Feide Integration + - **Provider**: Norwegian education federation SSO - **Flow**: OAuth 2.0 with Arctic library - **User Creation**: Automatic on first Feide login (requires invitation) ### Role-Based Access + - `normal` - Basic portal access, can volunteer for shifts - `board` - Admin access to user management, event creation, reporting - Route protection: `/portal/admin/*` requires board role @@ -129,11 +139,13 @@ pnpm dlx tsx ./apps/www/scripts/users.ts ## Portal System ### User Portal (`/portal/`) + - Event browsing and shift volunteering - Personal profile and notification management - Beer credit tracking and claiming ### Admin Portal (`/portal/admin/`) + - User management (roles, credits, deletion) - Event creation with shift scheduling - Volunteer assignment and management @@ -142,37 +154,44 @@ pnpm dlx tsx ./apps/www/scripts/users.ts ## Development Patterns ### Form Handling + Use SvelteKit's enhanced forms with `use:enhance` for AJAX submissions. Always call `invalidateAll()` after successful mutations to refresh data. ### Service Usage + Access services via `locals` in server actions/load functions: + ```typescript export const actions = { - default: async ({ locals, request }) => { - await locals.userService.updateUser(userId, data); - return { success: true }; - } + default: async ({ locals, request }) => { + await locals.userService.updateUser(userId, data); + return { success: true }; + } }; ``` ### Database Migrations + - Schema changes: Edit `src/lib/db/schemas/index.ts` - Generate migration: `pnpm db:generate` - Apply locally: `pnpm db:migrate:local` - Production migrations run automatically on deployment ### Email Templates + Email templates are in `internal/emails/src/templates/`. Use `EmailService` to send with proper rendering and delivery via Resend. ## Deployment ### Cloudflare Pages + - Automatic deployment on `main` branch merges - Database migrations applied automatically - Environment variables configured in Cloudflare dashboard - Domain: `programmer.bar` ### Required Environment Variables + - `RESEND_API_KEY` - Email delivery service - `FEIDE_CLIENT_ID` & `FEIDE_CLIENT_SECRET` - OAuth authentication - Database credentials configured via Wrangler for D1 access @@ -180,11 +199,13 @@ Email templates are in `internal/emails/src/templates/`. Use `EmailService` to s ## Common Issues ### First-Time Setup + Users must have invitations created before they can log in via Feide. Use the invitation script after setting up the local environment. ### Service Dependencies + All services require database and auth initialization. Check `src/hooks.server.ts` for service dependency injection setup. ### Migration Failures -If migrations fail, check D1 database status in Cloudflare dashboard and ensure proper credentials in `drizzle.config.ts`. +If migrations fail, check D1 database status in Cloudflare dashboard and ensure proper credentials in `drizzle.config.ts`. diff --git a/README.md b/README.md index fe6f0111..f223006a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 1. Fill in env-variables ```bash - cp apps/www/.dev.vars.example .dev.vars + cp apps/www/.env.example .env ``` 1. Fill in the empty variables @@ -35,6 +35,7 @@ - Website will run on [http://localhost:5173](http://localhost:5173) - Sanity will run on [http://localhost:3333](http://localhost:3333) +- API will run on [http://localhost:8000](http://localhost:8000) ### Add invitation diff --git a/apps/api/migrations/20250823194905_pending_application_and_can_refer.sql b/apps/api/migrations/20250823194905_pending_application_and_can_refer.sql new file mode 100644 index 00000000..0521329e --- /dev/null +++ b/apps/api/migrations/20250823194905_pending_application_and_can_refer.sql @@ -0,0 +1,16 @@ +CREATE TABLE pending_application ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + feide_id TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX pending_application_email_idx ON pending_application (email); + +CREATE INDEX pending_application_feide_id_idx ON pending_application (feide_id); + +ALTER TABLE + "user" +ADD + can_refer boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index bf4d5402..68c06a64 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "cargo run", + "dev": "cargo watch -x run", "build": "cargo build --release", "start": "cargo run --release", "test": "cargo test", @@ -11,8 +11,8 @@ "lint:fix": "cargo clippy --fix --allow-dirty -- -D warnings", "format": "cargo fmt", "format:check": "cargo fmt --check", - "clean": "cargo clean", - "add-migration": "sqlx migrate add", - "run-migrations": "sqlx migrate run" + "clean": "cargo clean && rm -rf .turbo", + "db:generate": "sqlx migrate add", + "db:migrate": "sqlx migrate run" } } \ No newline at end of file diff --git a/apps/api/src/config.rs b/apps/api/src/config.rs index 28f6dacf..efb708c8 100644 --- a/apps/api/src/config.rs +++ b/apps/api/src/config.rs @@ -2,19 +2,34 @@ use oauth2::{ClientId, ClientSecret, RedirectUrl}; #[derive(Debug, Clone)] pub struct Config { + /// Database connection URL pub database_url: String, + /// Server port to run the application on pub server_port: u16, + /// Feide OAuth2 client ID pub feide_client_id: ClientId, + /// Feide OAuth2 client secret pub feide_client_secret: ClientSecret, + /// Feide OAuth2 redirect URI pub feide_redirect_uri: RedirectUrl, + /// Flag indicating if the application is running in development mode pub is_dev: bool, + /// S3 storage configuration pub s3_endpoint: String, + /// S3 access key pub s3_access_key: String, + /// S3 secret key pub s3_secret_key: String, + /// S3 region pub s3_region: String, + /// S3 bucket name pub s3_bucket: String, + /// Redis connection URL pub redis_url: String, + /// Admin key for privileged operations pub admin_key: String, + /// Redirect URL for when logging in from the web-app + pub web_redirect_url: Option, } impl Config { @@ -63,7 +78,9 @@ impl Config { let admin_key = std::env::var("ADMIN_KEY").expect("Expected ADMIN_KEY environment variable to be set"); - Config { + let web_redirect_url = std::env::var("WEB_REDIRECT_URL").ok(); + + Self { database_url, server_port, feide_client_id, @@ -77,6 +94,7 @@ impl Config { s3_bucket, redis_url, admin_key, + web_redirect_url, } } } diff --git a/apps/api/src/dto/admin.rs b/apps/api/src/dto/admin.rs new file mode 100644 index 00000000..214d529c --- /dev/null +++ b/apps/api/src/dto/admin.rs @@ -0,0 +1,36 @@ +use serde::Serialize; +use utoipa::ToSchema; + +use crate::models::user::User; + +#[derive(Serialize, ToSchema)] +pub struct UserResponse { + /// Unique identifier for the user + pub id: String, + /// Name of the user + pub name: String, + /// Email address of the user + pub email: String, + /// Optional Feide ID for the user + pub feide_id: Option, + /// Role of the user in the system + pub role: String, + /// Number of extra beers the user can claim + pub additional_beers: i32, + /// Optional alternative email address for the user + pub alt_email: Option, +} + +impl From for UserResponse { + fn from(user: User) -> Self { + Self { + id: user.id, + name: user.name, + email: user.email, + feide_id: user.feide_id, + role: user.role, + additional_beers: user.additional_beers, + alt_email: user.alt_email, + } + } +} diff --git a/apps/api/src/dto/mod.rs b/apps/api/src/dto/mod.rs index 74b7c04f..de230a05 100644 --- a/apps/api/src/dto/mod.rs +++ b/apps/api/src/dto/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod event; pub mod image; pub mod product; @@ -5,6 +6,7 @@ pub mod root; pub mod status; pub mod user; +pub use admin::*; pub use event::*; pub use image::*; pub use product::*; diff --git a/apps/api/src/extractors/auth.rs b/apps/api/src/extractors/auth.rs index 8b162919..b566c9fe 100644 --- a/apps/api/src/extractors/auth.rs +++ b/apps/api/src/extractors/auth.rs @@ -14,7 +14,7 @@ impl FromRequestParts for AuthorizedMember { parts: &mut Parts, state: &AppState, ) -> Result { - let session_id = extract_session_cookie(parts)?; + let session_id = extract_session_cookie(parts, state.key.clone())?; let (session, user) = state.auth_service.validate_session(&session_id).await?; Ok(AuthorizedMember { session, user }) @@ -28,7 +28,7 @@ impl FromRequestParts for AuthorizedBoardMember { parts: &mut Parts, state: &AppState, ) -> Result { - let session_id = extract_session_cookie(parts)?; + let session_id = extract_session_cookie(parts, state.key.clone())?; let (session, user) = state.auth_service.validate_session(&session_id).await?; if !user.is_board_member() { diff --git a/apps/api/src/extractors/mod.rs b/apps/api/src/extractors/mod.rs index 4cb9c34b..74fd0596 100644 --- a/apps/api/src/extractors/mod.rs +++ b/apps/api/src/extractors/mod.rs @@ -1,5 +1,3 @@ pub mod admin; pub mod auth; mod utils; - -pub const SESSION_COOKIE_NAME: &str = "session_token"; diff --git a/apps/api/src/extractors/utils.rs b/apps/api/src/extractors/utils.rs index 02392e7a..fe872d0c 100644 --- a/apps/api/src/extractors/utils.rs +++ b/apps/api/src/extractors/utils.rs @@ -1,22 +1,15 @@ -use axum::http::{header::COOKIE, request::Parts}; +use axum::http::request::Parts; +use axum_extra::extract::PrivateCookieJar; +use cookie::Key; -use crate::{errors::ApiError, extractors::SESSION_COOKIE_NAME}; +use crate::errors::ApiError; +use crate::services::session::SESSION_COOKIE_NAME; -pub fn extract_session_cookie(parts: &Parts) -> Result { - let cookies = parts - .headers - .get(COOKIE) - .and_then(|value| value.to_str().ok()) - .ok_or(ApiError::Unauthorized)?; - - for cookie in cookies.split(';') { - let cookie = cookie.trim(); - if let Some(session_id) = cookie.strip_prefix(format!("{SESSION_COOKIE_NAME}=").as_str()) { - return Ok(session_id.to_string()); - } - } - - Err(ApiError::Unauthorized) +pub fn extract_session_cookie(parts: &Parts, key: Key) -> Result { + PrivateCookieJar::from_headers(&parts.headers, key) + .get(SESSION_COOKIE_NAME) + .map(|cookie| cookie.value().to_string()) + .ok_or(ApiError::Unauthorized) } pub fn get_bearer_token(parts: &Parts) -> Option { diff --git a/apps/api/src/handlers/admin.rs b/apps/api/src/handlers/admin.rs index 340a1584..937d1f2b 100644 --- a/apps/api/src/handlers/admin.rs +++ b/apps/api/src/handlers/admin.rs @@ -1,8 +1,11 @@ use std::collections::HashMap; -use axum::Json; +use axum::{ + Json, + extract::{Path, State}, +}; -use crate::{dto, extractors::admin::AdminKey}; +use crate::{dto, errors::ApiError, extractors::admin::AdminKey, state::AppState}; /// Test endpoint to verify it checks the admin key #[utoipa::path( @@ -16,7 +19,7 @@ use crate::{dto, extractors::admin::AdminKey}; ), tag = "Admin" )] -pub async fn test_admin(_admin_key: AdminKey) -> Json>> { +pub async fn admin_test(_admin_key: AdminKey) -> Json>> { let mut info = HashMap::new(); info.insert("you are".to_string(), "admin".to_string()); @@ -25,3 +28,34 @@ pub async fn test_admin(_admin_key: AdminKey) -> Json), + (status = 404, description = "User not found"), + (status = 403, description = "Forbidden") + ), + security( + ("admin_key" = []) + ), + tag = "Admin" +)] +pub async fn admin_get_user( + State(state): State, + Path(user_id): Path, + _admin_key: AdminKey, +) -> Result>, ApiError> { + let user = state + .user_service + .find_active_by_id(&user_id) + .await + .map_err(|_| ApiError::NotFound(format!("Failed to find a user with the id {user_id}")))?; + + Ok(Json(dto::RootResponse { + success: true, + data: Some(dto::UserResponse::from(user)), + })) +} diff --git a/apps/api/src/handlers/auth.rs b/apps/api/src/handlers/auth.rs index 5ae2d31b..6121cf77 100644 --- a/apps/api/src/handlers/auth.rs +++ b/apps/api/src/handlers/auth.rs @@ -1,6 +1,6 @@ use crate::{ - errors::ApiError, models::auth::AuthorizedMember, services::session::SessionService, - state::AppState, + config::Config, errors::ApiError, models::auth::AuthorizedMember, + services::session::SessionService, state::AppState, }; use axum::{ extract::{Query, State}, @@ -12,6 +12,22 @@ use oauth2::CsrfToken; use reqwest::StatusCode; use serde::Deserialize; +fn determine_redirect_url(jar: &CookieJar, config: &Config) -> String { + if let Some(app_cookie) = jar.get("app") { + match app_cookie.value() { + "web" => config.web_redirect_url.clone().unwrap_or("/".to_string()), + _ => "/".to_string(), + } + } else { + "/".to_string() + } +} + +#[derive(Deserialize)] +pub struct AuthParams { + app: Option, +} + #[derive(Deserialize)] pub struct CallbackParams { code: String, @@ -36,8 +52,9 @@ pub struct CallbackParams { )] pub async fn feide_auth( State(state): State, + Query(params): Query, jar: CookieJar, -) -> Result<(CookieJar, Redirect), ApiError> { +) -> Result { let csrf_state = CsrfToken::new_random(); // Generate authorization URL with CSRF state @@ -47,11 +64,19 @@ pub async fn feide_auth( .map_err(|_| ApiError::InternalServerError)?; // Store CSRF state in a regular cookie (not encrypted) - let c = Cookie::build(("feide_oauth_state", csrf_state.into_secret())) + let state_cookie = Cookie::build(("feide_oauth_state", csrf_state.into_secret())) .max_age(Duration::seconds(600)) .http_only(true) .secure(!state.config.is_dev); - let jar = jar.add(c); + + let app_cookie_value = params.app.unwrap_or("api".to_string()); + let app_cookie = Cookie::build(("app", app_cookie_value)) + .max_age(Duration::seconds(600)) + .http_only(true) + .secure(!state.config.is_dev); + + // Add cookies to the jar + let jar = jar.add(state_cookie).add(app_cookie); Ok((jar, Redirect::to(&auth_url))) } @@ -114,33 +139,26 @@ pub async fn feide_callback( .map_err(|_| ApiError::InternalServerError)?; // Check if user exists by Feide ID - let existing_user = state - .user_repo - .get_by_feide_id(&feide_user.sub) - .await - .map_err(|_| ApiError::InternalServerError)?; + let existing_user = state.user_service.get_by_feide_id(&feide_user.sub).await?; if let Some(user) = existing_user { // User exists - // Create session and redirect to portal - let session = state - .session_service - .create_session(user.id) - .await - .map_err(|_| ApiError::InternalServerError)?; + // Create session and redirect based on app parameter + let session = state.session_service.create_session(user.id).await?; let session_cookie = state.session_service.create_session_cookie(&session.id); let updated_jar = private_jar.add(session_cookie); - return Ok((updated_jar, Redirect::to("/"))); + // Determine redirect URL based on app parameter + let redirect_url = determine_redirect_url(®ular_jar, &state.config); + return Ok((updated_jar, Redirect::to(&redirect_url))); } // Check for valid invitation let invitation = state - .invitation_repo + .invitation_service .get_by_email(&feide_user.email) - .await - .map_err(|_| ApiError::InternalServerError)?; + .await?; let invitation = invitation.ok_or(ApiError::BadRequest( "No valid invitation found for this email".to_string(), @@ -148,41 +166,28 @@ pub async fn feide_callback( // Create new user let user_id = uuid::Uuid::new_v4().to_string(); - let new_user = crate::models::user::User { - id: user_id.clone(), - name: feide_user.name, - email: feide_user.email, - feide_id: Some(feide_user.sub), - role: "normal".to_string(), - additional_beers: 0, - alt_email: None, - is_deleted: false, - }; + let new_user = crate::models::user::User::create( + user_id.clone(), + feide_user.name, + feide_user.email, + feide_user.sub, + ); - state - .user_repo - .create(&new_user) - .await - .map_err(|_| ApiError::InternalServerError)?; + // Insert the new user + state.user_service.create(&new_user).await?; // Mark invitation as used - state - .invitation_repo - .delete(&invitation.id) - .await - .map_err(|_| ApiError::InternalServerError)?; + state.invitation_service.claim(&invitation.id).await?; // Create session for new user - let session = state - .session_service - .create_session(user_id) - .await - .map_err(|_| ApiError::InternalServerError)?; + let session = state.session_service.create_session(user_id).await?; let session_cookie = state.session_service.create_session_cookie(&session.id); let updated_jar = private_jar.add(session_cookie); - Ok((updated_jar, Redirect::to("/"))) + // Determine redirect URL based on app parameter + let redirect_url = determine_redirect_url(®ular_jar, &state.config); + Ok((updated_jar, Redirect::to(&redirect_url))) } /// Log out the authenticated user. @@ -210,13 +215,12 @@ pub async fn logout( State(state): State, jar: PrivateCookieJar, auth: AuthorizedMember, -) -> Result<(PrivateCookieJar, StatusCode), ApiError> { +) -> Result { // Delete session from database state .session_service .delete_session(&auth.session.id) - .await - .map_err(|_| ApiError::InternalServerError)?; + .await?; // Create logout cookie (expires immediately) let logout_cookie = SessionService::create_logout_cookie(); diff --git a/apps/api/src/handlers/event.rs b/apps/api/src/handlers/event.rs index 66880f57..f46e068c 100644 --- a/apps/api/src/handlers/event.rs +++ b/apps/api/src/handlers/event.rs @@ -30,7 +30,7 @@ pub async fn all_events( _auth: AuthorizedMember, ) -> Result>, ApiError> { let events = state - .event_repo + .event_service .all_with_shifts() .await .map_err(|_| ApiError::InternalServerError)?; diff --git a/apps/api/src/handlers/image.rs b/apps/api/src/handlers/image.rs index 9cc95d2a..849500f2 100644 --- a/apps/api/src/handlers/image.rs +++ b/apps/api/src/handlers/image.rs @@ -106,10 +106,14 @@ pub async fn upload_image( updated_at: Utc::now(), }; - state.image_repo.create(&image_record).await.map_err(|e| { - tracing::error!("Database insert failed: {}", e); - ApiError::InternalServerError - })?; + state + .image_service + .create(&image_record) + .await + .map_err(|e| { + tracing::error!("Database insert failed: {}", e); + ApiError::InternalServerError + })?; Ok(Json(dto::ImageResponse { id: file_id, @@ -156,7 +160,7 @@ pub async fn get_image_by_id( })?; let image = state - .image_repo + .image_service .get_by_id(&id) .await .map_err(|_| ApiError::InternalServerError)? diff --git a/apps/api/src/handlers/mod.rs b/apps/api/src/handlers/mod.rs index 40cb01a6..dd778673 100644 --- a/apps/api/src/handlers/mod.rs +++ b/apps/api/src/handlers/mod.rs @@ -7,3 +7,4 @@ pub mod products; pub mod profile; pub mod root; pub mod status; +pub mod user; diff --git a/apps/api/src/handlers/products.rs b/apps/api/src/handlers/products.rs index 440aa1d5..aeae2ea5 100644 --- a/apps/api/src/handlers/products.rs +++ b/apps/api/src/handlers/products.rs @@ -25,7 +25,7 @@ use crate::{ )] pub async fn all_products(State(state): State) -> Json> { let products = state - .product_repo + .product_service .list() .await .unwrap_or_else(|_| vec![]) @@ -62,7 +62,7 @@ pub async fn get_product_by_id( Path(id): Path, ) -> Result, ApiError> { let product = state - .product_repo + .product_service .get_by_id(&id) .await .map_err(|_| ApiError::InternalServerError)? @@ -122,7 +122,7 @@ pub async fn create_product( }; let created_product = state - .product_repo + .product_service .create(&new_product) .await .map_err(|_| ApiError::InternalServerError)?; diff --git a/apps/api/src/handlers/user.rs b/apps/api/src/handlers/user.rs new file mode 100644 index 00000000..71a887d0 --- /dev/null +++ b/apps/api/src/handlers/user.rs @@ -0,0 +1,19 @@ +use crate::errors::ApiError; +use axum::{Json, extract::State, response::IntoResponse}; + +use crate::{dto::User, state::AppState}; + +/// List all users, by default it only lists the active users. +#[utoipa::path( + get, + path = "/users", + responses( + (status = 200, description = "A list of users", body = Vec), + (status = 500, description = "Internal server error") + ), + tag = "Users" +)] +pub async fn list_users(State(state): State) -> Result { + let users = state.user_service.list_active().await?; + Ok(Json(users)) +} diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 1634aa3c..f2ff402c 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -14,7 +14,6 @@ mod handlers; mod models; mod openapi; mod providers; -mod repositories; mod services; mod state; @@ -25,40 +24,59 @@ async fn main() -> anyhow::Result<()> { let config = Config::from_env(); let state = AppState::from_config(config.clone()).await; - let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) - // General endpoints + // General endpoints + let router = OpenApiRouter::with_openapi(ApiDoc::openapi()) .routes(routes![handlers::root::root]) - .routes(routes![handlers::health::health]) - // Product endpoints + .routes(routes![handlers::health::health]); + + // Product endpoints + let router = router .routes(routes![handlers::products::all_products]) .routes(routes![handlers::products::create_product]) - .routes(routes![handlers::products::get_product_by_id]) - // Event endpoints - .routes(routes![handlers::event::all_events]) - // Authentication endpoints + .routes(routes![handlers::products::get_product_by_id]); + + // Event endpoints + let router = router.routes(routes![handlers::event::all_events]); + + // Authentication endpoints + let router = router .routes(routes![handlers::auth::feide_auth]) .routes(routes![handlers::auth::feide_callback]) - .routes(routes![handlers::auth::logout]) - // User endpoints - .routes(routes![handlers::profile::get_profile]) - // Image endpoints + .routes(routes![handlers::auth::logout]); + + // User endpoints + let router = router.routes(routes![handlers::profile::get_profile]); + + // Image endpoints + let router = router .routes(routes![handlers::image::upload_image]) - .routes(routes![handlers::image::get_image_by_id]) - // Status endpoints + .routes(routes![handlers::image::get_image_by_id]); + + // Status endpoints + let router = router .routes(routes![handlers::status::status]) - .routes(routes![handlers::status::set_status]) - // Admin endpoints - .routes(routes![handlers::admin::test_admin]) - .split_for_parts(); - - let app = router - .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api)) - // Middleware / Layers + .routes(routes![handlers::status::set_status]); + + // Admin endpoints + let router = router + .routes(routes![handlers::admin::admin_test]) + .routes(routes![handlers::admin::admin_get_user]); + + // User endpoints + let router = router.routes(routes![handlers::user::list_users]); + + let (router, api) = router.split_for_parts(); + + let app = router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api)); + + // Middleware / Layers + let app = app .layer(cors_layer()) .layer(TraceLayer::new_for_http()) - .layer(NormalizePathLayer::trim_trailing_slash()) - // State - .with_state(state); + .layer(NormalizePathLayer::trim_trailing_slash()); + + // State + let app = app.with_state(state); let port = config.server_port; let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await?; @@ -88,17 +106,16 @@ fn init_tracing() { fn cors_layer() -> CorsLayer { CorsLayer::new() - .allow_origin( - "http://localhost:5173" - .parse::() - .unwrap(), - ) // SvelteKit dev server - .allow_origin( - "https://programmer.bar" - .parse::() - .unwrap(), - ) // Production - .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_origin([ + "http://localhost:5173".parse().unwrap(), // Dev frontend + "https://programmer.bar".parse().unwrap(), // Production frontend + ]) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::DELETE, + ]) .allow_headers([ axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION, diff --git a/apps/api/src/models/user.rs b/apps/api/src/models/user.rs index 3cc630d3..f5036ba1 100644 --- a/apps/api/src/models/user.rs +++ b/apps/api/src/models/user.rs @@ -14,6 +14,19 @@ pub struct User { } impl User { + pub fn create(id: String, name: String, email: String, feide_id: String) -> Self { + Self { + id, + name, + email, + feide_id: Some(feide_id), + role: "normal".to_string(), + additional_beers: 0, + alt_email: None, + is_deleted: false, + } + } + pub fn is_board_member(&self) -> bool { self.role == "board" } diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 39acbc21..3e4e59fc 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -3,7 +3,7 @@ use utoipa::{ openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, }; -use crate::extractors::SESSION_COOKIE_NAME; +use crate::services::session::SESSION_COOKIE_NAME; #[derive(OpenApi)] #[openapi( @@ -17,7 +17,8 @@ use crate::extractors::SESSION_COOKIE_NAME; (name = "Profile", description = "User profile management"), (name = "Images", description = "Image upload and retrieval"), (name = "Status", description = "Status of the bar"), - (name = "Admin", description = "Admin endpoints for BFF operations") + (name = "Admin", description = "Admin endpoints for BFF operations"), + (name = "Users", description = "User management endpoints") ) )] pub struct ApiDoc; diff --git a/apps/api/src/repositories/claimed_credit.rs b/apps/api/src/repositories/claimed_credit.rs deleted file mode 100644 index 549fbb3f..00000000 --- a/apps/api/src/repositories/claimed_credit.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::models::claimed_credit::ClaimedCredit; -use sqlx::{PgPool, query, query_as}; - -pub struct ClaimedCreditRepository { - pool: PgPool, -} - -impl ClaimedCreditRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id(&self, id: &str) -> Result, sqlx::Error> { - query_as!( - ClaimedCredit, - "SELECT * FROM claimed_credit WHERE id = $1", - id - ) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, claimed_credit: &ClaimedCredit) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO claimed_credit (id, user_id, product_id, credit_cost, created_at) VALUES ($1, $2, $3, $4, $5)", - claimed_credit.id, - claimed_credit.user_id, - claimed_credit.product_id, - claimed_credit.credit_cost, - claimed_credit.created_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update(&self, claimed_credit: &ClaimedCredit) -> Result<(), sqlx::Error> { - query!( - "UPDATE claimed_credit SET user_id = $2, product_id = $3, credit_cost = $4, created_at = $5 WHERE id = $1", - claimed_credit.id, - claimed_credit.user_id, - claimed_credit.product_id, - claimed_credit.credit_cost, - claimed_credit.created_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { - query!("DELETE FROM claimed_credit WHERE id = $1", id) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/contact_submission.rs b/apps/api/src/repositories/contact_submission.rs deleted file mode 100644 index 675480fb..00000000 --- a/apps/api/src/repositories/contact_submission.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::models::contact_submission::ContactSubmission; -use sqlx::{PgPool, query, query_as}; - -pub struct ContactSubmissionRepository { - pool: PgPool, -} - -impl ContactSubmissionRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id(&self, id: &str) -> Result, sqlx::Error> { - query_as!( - ContactSubmission, - "SELECT * FROM contact_submission WHERE id = $1", - id - ) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, contact_submission: &ContactSubmission) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO contact_submission (id, name, email, message, submitted_at, ip_address) VALUES ($1, $2, $3, $4, $5, $6)", - contact_submission.id, - contact_submission.name, - contact_submission.email, - contact_submission.message, - contact_submission.submitted_at, - contact_submission.ip_address - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update(&self, contact_submission: &ContactSubmission) -> Result<(), sqlx::Error> { - query!( - "UPDATE contact_submission SET name = $2, email = $3, message = $4, submitted_at = $5, ip_address = $6 WHERE id = $1", - contact_submission.id, - contact_submission.name, - contact_submission.email, - contact_submission.message, - contact_submission.submitted_at, - contact_submission.ip_address - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { - query!("DELETE FROM contact_submission WHERE id = $1", id) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/group.rs b/apps/api/src/repositories/group.rs deleted file mode 100644 index 1eea2deb..00000000 --- a/apps/api/src/repositories/group.rs +++ /dev/null @@ -1,18 +0,0 @@ -use crate::models::group::Group; -use sqlx::{PgPool, query_as}; - -pub struct GroupRepository { - pool: PgPool, -} - -impl GroupRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id(&self, id: &str) -> Result, sqlx::Error> { - query_as!(Group, "SELECT * FROM \"group\" WHERE id = $1", id) - .fetch_optional(&self.pool) - .await - } -} diff --git a/apps/api/src/repositories/invitation.rs b/apps/api/src/repositories/invitation.rs deleted file mode 100644 index 5ace3eb0..00000000 --- a/apps/api/src/repositories/invitation.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::models::invitation::Invitation; -use sqlx::{PgPool, query, query_as}; - -pub struct InvitationRepository { - pool: PgPool, -} - -impl InvitationRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id(&self, id: &str) -> Result, sqlx::Error> { - query_as!(Invitation, "SELECT * FROM invitation WHERE id = $1", id) - .fetch_optional(&self.pool) - .await - } - - pub async fn get_by_email(&self, email: &str) -> Result, sqlx::Error> { - query_as!( - Invitation, - "SELECT * FROM invitation WHERE email = $1 AND claimed_at IS NULL AND expires_at > NOW()", - email - ) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, invitation: &Invitation) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO invitation (id, email, claimed_at, created_at, expires_at) VALUES ($1, $2, $3, $4, $5)", - invitation.id, - invitation.email, - invitation.claimed_at, - invitation.created_at, - invitation.expires_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update(&self, invitation: &Invitation) -> Result<(), sqlx::Error> { - query!( - "UPDATE invitation SET email = $2, claimed_at = $3, created_at = $4, expires_at = $5 WHERE id = $1", - invitation.id, - invitation.email, - invitation.claimed_at, - invitation.created_at, - invitation.expires_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { - query!("DELETE FROM invitation WHERE id = $1", id) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/mod.rs b/apps/api/src/repositories/mod.rs deleted file mode 100644 index a04344aa..00000000 --- a/apps/api/src/repositories/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -pub mod claimed_credit; -pub mod contact_submission; -pub mod event; -pub mod group; -pub mod image; -pub mod invitation; -pub mod notification; -pub mod producer; -pub mod product; -pub mod product_product_type; -pub mod product_type; -pub mod session; -pub mod shift; -pub mod user; -pub mod user_shift; -pub mod users_group; diff --git a/apps/api/src/repositories/notification.rs b/apps/api/src/repositories/notification.rs deleted file mode 100644 index 307b715e..00000000 --- a/apps/api/src/repositories/notification.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::models::notification::Notification; -use sqlx::{PgPool, query, query_as}; - -pub struct NotificationRepository { - pool: PgPool, -} - -impl NotificationRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id(&self, id: &str) -> Result, sqlx::Error> { - query_as!(Notification, "SELECT * FROM notification WHERE id = $1", id) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, notification: &Notification) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO notification (id, user_id, title, body, archived_at, created_at) VALUES ($1, $2, $3, $4, $5, $6)", - notification.id, - notification.user_id, - notification.title, - notification.body, - notification.archived_at, - notification.created_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update(&self, notification: &Notification) -> Result<(), sqlx::Error> { - query!( - "UPDATE notification SET user_id = $2, title = $3, body = $4, archived_at = $5, created_at = $6 WHERE id = $1", - notification.id, - notification.user_id, - notification.title, - notification.body, - notification.archived_at, - notification.created_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { - query!("DELETE FROM notification WHERE id = $1", id) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/producer.rs b/apps/api/src/repositories/producer.rs deleted file mode 100644 index 7b659f8a..00000000 --- a/apps/api/src/repositories/producer.rs +++ /dev/null @@ -1,53 +0,0 @@ -use crate::models::producer::Producer; -use sqlx::{PgPool, query, query_as}; - -pub struct ProducerRepository { - pool: PgPool, -} - -impl ProducerRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id(&self, id: &str) -> Result, sqlx::Error> { - query_as!(Producer, "SELECT * FROM producer WHERE id = $1", id) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, producer: &Producer) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO producer (id, name, image_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)", - producer.id, - producer.name, - producer.image_id, - producer.created_at, - producer.updated_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update(&self, producer: &Producer) -> Result<(), sqlx::Error> { - query!( - "UPDATE producer SET name = $2, image_id = $3, created_at = $4, updated_at = $5 WHERE id = $1", - producer.id, - producer.name, - producer.image_id, - producer.created_at, - producer.updated_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { - query!("DELETE FROM producer WHERE id = $1", id) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/product_product_type.rs b/apps/api/src/repositories/product_product_type.rs deleted file mode 100644 index b6a5c996..00000000 --- a/apps/api/src/repositories/product_product_type.rs +++ /dev/null @@ -1,52 +0,0 @@ -use crate::models::product_product_type::ProductProductType; -use sqlx::{PgPool, query, query_as}; - -pub struct ProductProductTypeRepository { - pool: PgPool, -} - -impl ProductProductTypeRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id( - &self, - product_id: &str, - product_type_id: &str, - ) -> Result, sqlx::Error> { - query_as!( - ProductProductType, - "SELECT * FROM product_product_types WHERE product_id = $1 AND product_type_id = $2", - product_id, - product_type_id - ) - .fetch_optional(&self.pool) - .await - } - - pub async fn create( - &self, - product_product_type: &ProductProductType, - ) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO product_product_types (product_id, product_type_id) VALUES ($1, $2)", - product_product_type.product_id, - product_product_type.product_type_id - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, product_id: &str, product_type_id: &str) -> Result<(), sqlx::Error> { - query!( - "DELETE FROM product_product_types WHERE product_id = $1 AND product_type_id = $2", - product_id, - product_type_id - ) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/product_type.rs b/apps/api/src/repositories/product_type.rs deleted file mode 100644 index 407c75c7..00000000 --- a/apps/api/src/repositories/product_type.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::models::product_type::ProductType; -use sqlx::{PgPool, query, query_as}; - -pub struct ProductTypeRepository { - pool: PgPool, -} - -impl ProductTypeRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id(&self, id: &str) -> Result, sqlx::Error> { - query_as!(ProductType, "SELECT * FROM product_type WHERE id = $1", id) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, product_type: &ProductType) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO product_type (id, title, created_at, updated_at) VALUES ($1, $2, $3, $4)", - product_type.id, - product_type.title, - product_type.created_at, - product_type.updated_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update(&self, product_type: &ProductType) -> Result<(), sqlx::Error> { - query!( - "UPDATE product_type SET title = $2, created_at = $3, updated_at = $4 WHERE id = $1", - product_type.id, - product_type.title, - product_type.created_at, - product_type.updated_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { - query!("DELETE FROM product_type WHERE id = $1", id) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/session.rs b/apps/api/src/repositories/session.rs deleted file mode 100644 index 7e21f9c4..00000000 --- a/apps/api/src/repositories/session.rs +++ /dev/null @@ -1,59 +0,0 @@ -use chrono::Utc; -use sqlx::{Pool, Postgres, query, query_as}; - -use crate::{errors::ApiError, models::session::Session}; - -pub struct SessionRepository { - pool: Pool, -} - -impl SessionRepository { - pub fn new(pool: Pool) -> Self { - Self { pool } - } - - pub async fn create(&self, session: &Session) -> sqlx::Result<()> { - query!( - "INSERT INTO session (id, user_id, expires_at) VALUES ($1, $2, $3)", - session.id, - session.user_id, - session.expires_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn get_by_id(&self, session_id: &str) -> Result, sqlx::Error> { - query_as!( - Session, - "SELECT id, user_id, expires_at FROM session WHERE id = $1", - session_id - ) - .fetch_optional(&self.pool) - .await - } - - pub async fn find_valid_by_id(&self, session_id: &str) -> Result { - query_as!( - Session, - "SELECT id, user_id, expires_at FROM session WHERE id = $1 AND expires_at > $2", - session_id, - Utc::now() - ) - .fetch_one(&self.pool) - .await - .map_err(|err| match err { - sqlx::Error::RowNotFound => ApiError::Unauthorized, - _ => ApiError::InternalServerError, - }) - } - - pub async fn delete(&self, session_id: &str) -> Result<(), ApiError> { - query!("DELETE FROM session WHERE id = $1", session_id) - .execute(&self.pool) - .await - .map_err(|_| ApiError::InternalServerError)?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/shift.rs b/apps/api/src/repositories/shift.rs deleted file mode 100644 index 12d54815..00000000 --- a/apps/api/src/repositories/shift.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::models::shift::Shift; -use sqlx::{PgPool, query, query_as}; - -pub struct ShiftRepository { - pool: PgPool, -} - -impl ShiftRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id(&self, id: &str) -> Result, sqlx::Error> { - query_as!(Shift, "SELECT * FROM shift WHERE id = $1", id) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, shift: &Shift) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO shift (id, event_id, start_at, end_at) VALUES ($1, $2, $3, $4)", - shift.id, - shift.event_id, - shift.start_at, - shift.end_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update(&self, shift: &Shift) -> Result<(), sqlx::Error> { - query!( - "UPDATE shift SET event_id = $2, start_at = $3, end_at = $4 WHERE id = $1", - shift.id, - shift.event_id, - shift.start_at, - shift.end_at - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { - query!("DELETE FROM shift WHERE id = $1", id) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/user_shift.rs b/apps/api/src/repositories/user_shift.rs deleted file mode 100644 index 44921b11..00000000 --- a/apps/api/src/repositories/user_shift.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::models::user_shift::UserShift; -use sqlx::{PgPool, query, query_as}; - -pub struct UserShiftRepository { - pool: PgPool, -} - -impl UserShiftRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id( - &self, - user_id: &str, - shift_id: &str, - ) -> Result, sqlx::Error> { - query_as!( - UserShift, - "SELECT * FROM user_shift WHERE user_id = $1 AND shift_id = $2", - user_id, - shift_id - ) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, user_shift: &UserShift) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO user_shift (user_id, shift_id, created_at, updated_at, is_beer_claimed, status) VALUES ($1, $2, $3, $4, $5, $6)", - user_shift.user_id, - user_shift.shift_id, - user_shift.created_at, - user_shift.updated_at, - user_shift.is_beer_claimed, - user_shift.status - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update(&self, user_shift: &UserShift) -> Result<(), sqlx::Error> { - query!( - "UPDATE user_shift SET created_at = $3, updated_at = $4, is_beer_claimed = $5, status = $6 WHERE user_id = $1 AND shift_id = $2", - user_shift.user_id, - user_shift.shift_id, - user_shift.created_at, - user_shift.updated_at, - user_shift.is_beer_claimed, - user_shift.status - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, user_id: &str, shift_id: &str) -> Result<(), sqlx::Error> { - query!( - "DELETE FROM user_shift WHERE user_id = $1 AND shift_id = $2", - user_id, - shift_id - ) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/repositories/users_group.rs b/apps/api/src/repositories/users_group.rs deleted file mode 100644 index ddfd450f..00000000 --- a/apps/api/src/repositories/users_group.rs +++ /dev/null @@ -1,49 +0,0 @@ -use crate::models::users_group::UsersGroup; -use sqlx::{PgPool, query, query_as}; - -pub struct UsersGroupRepository { - pool: PgPool, -} - -impl UsersGroupRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn get_by_id( - &self, - user_id: &str, - group_id: &str, - ) -> Result, sqlx::Error> { - query_as!( - UsersGroup, - "SELECT * FROM users_groups WHERE user_id = $1 AND group_id = $2", - user_id, - group_id - ) - .fetch_optional(&self.pool) - .await - } - - pub async fn create(&self, users_group: &UsersGroup) -> Result<(), sqlx::Error> { - query!( - "INSERT INTO users_groups (user_id, group_id) VALUES ($1, $2)", - users_group.user_id, - users_group.group_id - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete(&self, user_id: &str, group_id: &str) -> Result<(), sqlx::Error> { - query!( - "DELETE FROM users_groups WHERE user_id = $1 AND group_id = $2", - user_id, - group_id - ) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/apps/api/src/services/auth.rs b/apps/api/src/services/auth.rs index 21056a5e..8236712f 100644 --- a/apps/api/src/services/auth.rs +++ b/apps/api/src/services/auth.rs @@ -1,29 +1,32 @@ use crate::{ errors::ApiError, models::{session::Session, user::User}, - repositories::{session::SessionRepository, user::UserRepository}, + services::{session::SessionService, user::UserService}, }; pub struct AuthService { - session_repo: SessionRepository, - user_repo: UserRepository, + session_service: SessionService, + user_service: UserService, } impl AuthService { - pub fn new(session_repo: SessionRepository, user_repo: UserRepository) -> Self { + pub fn new(session_service: SessionService, user_service: UserService) -> Self { Self { - session_repo, - user_repo, + session_service, + user_service, } } pub async fn validate_session(&self, session_id: &str) -> Result<(Session, User), ApiError> { - let session = self.session_repo.find_valid_by_id(session_id).await?; - let user = self.user_repo.find_active_by_id(&session.user_id).await?; + let session = self.session_service.find_valid_by_id(session_id).await?; + let user = self + .user_service + .find_active_by_id(&session.user_id) + .await?; Ok((session, user)) } pub async fn delete_session(&self, session_id: &str) -> Result<(), ApiError> { - self.session_repo.delete(session_id).await + self.session_service.delete_session(session_id).await } } diff --git a/apps/api/src/repositories/event.rs b/apps/api/src/services/event.rs similarity index 98% rename from apps/api/src/repositories/event.rs rename to apps/api/src/services/event.rs index 2cf773b2..4426e842 100644 --- a/apps/api/src/repositories/event.rs +++ b/apps/api/src/services/event.rs @@ -2,11 +2,11 @@ use crate::{dto::EventWithShifts, models::event::Event}; use sqlx::{FromRow, PgPool, query, query_as}; use std::collections::HashMap; -pub struct EventRepository { +pub struct EventService { pool: PgPool, } -impl EventRepository { +impl EventService { pub fn new(pool: PgPool) -> Self { Self { pool } } diff --git a/apps/api/src/repositories/image.rs b/apps/api/src/services/image.rs similarity index 96% rename from apps/api/src/repositories/image.rs rename to apps/api/src/services/image.rs index e59f3eee..b4eee41e 100644 --- a/apps/api/src/repositories/image.rs +++ b/apps/api/src/services/image.rs @@ -1,11 +1,11 @@ use crate::models::image::Image; use sqlx::{PgPool, query, query_as}; -pub struct ImageRepository { +pub struct ImageService { pool: PgPool, } -impl ImageRepository { +impl ImageService { pub fn new(pool: PgPool) -> Self { Self { pool } } diff --git a/apps/api/src/services/invitation.rs b/apps/api/src/services/invitation.rs new file mode 100644 index 00000000..9128e58e --- /dev/null +++ b/apps/api/src/services/invitation.rs @@ -0,0 +1,52 @@ +use crate::{errors::ApiError, models::invitation::Invitation}; +use chrono::Utc; +use sqlx::{Pool, Postgres, query, query_as}; + +#[derive(Clone)] +pub struct InvitationService { + pool: Pool, +} + +impl InvitationService { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + pub async fn get_by_email(&self, email: &str) -> Result, ApiError> { + query_as!( + Invitation, + "SELECT * FROM invitation WHERE email = $1 AND claimed_at IS NULL AND expires_at > NOW()", + email + ) + .fetch_optional(&self.pool) + .await + .map_err(|_| ApiError::InternalServerError) + } + + pub async fn create(&self, invitation: &Invitation) -> Result<(), ApiError> { + query!( + "INSERT INTO invitation (id, email, claimed_at, created_at, expires_at) VALUES ($1, $2, $3, $4, $5)", + invitation.id, + invitation.email, + invitation.claimed_at, + invitation.created_at, + invitation.expires_at + ) + .execute(&self.pool) + .await + .map_err(|_| ApiError::InternalServerError)?; + Ok(()) + } + + pub async fn claim(&self, id: &str) -> Result<(), ApiError> { + query!( + "UPDATE invitation SET claimed_at = $1 WHERE id = $2", + Utc::now(), + id + ) + .execute(&self.pool) + .await + .map_err(|_| ApiError::InternalServerError)?; + Ok(()) + } +} diff --git a/apps/api/src/services/mod.rs b/apps/api/src/services/mod.rs index 68bb8673..c053472e 100644 --- a/apps/api/src/services/mod.rs +++ b/apps/api/src/services/mod.rs @@ -1,3 +1,8 @@ pub mod auth; +pub mod event; +pub mod image; +pub mod invitation; +pub mod product; pub mod session; pub mod status; +pub mod user; diff --git a/apps/api/src/repositories/product.rs b/apps/api/src/services/product.rs similarity index 98% rename from apps/api/src/repositories/product.rs rename to apps/api/src/services/product.rs index 76c83992..d9a35a3b 100644 --- a/apps/api/src/repositories/product.rs +++ b/apps/api/src/services/product.rs @@ -1,11 +1,11 @@ use crate::models::product::Product; use sqlx::{PgPool, query, query_as}; -pub struct ProductRepository { +pub struct ProductService { pool: PgPool, } -impl ProductRepository { +impl ProductService { pub fn new(pool: PgPool) -> Self { Self { pool } } diff --git a/apps/api/src/services/session.rs b/apps/api/src/services/session.rs index fb0c978e..d97bda2f 100644 --- a/apps/api/src/services/session.rs +++ b/apps/api/src/services/session.rs @@ -1,15 +1,19 @@ -use crate::{errors::ApiError, models::session::Session, repositories::session::SessionRepository}; +use crate::{errors::ApiError, models::session::Session}; use axum_extra::extract::cookie::{Cookie, SameSite}; use chrono::{Duration, Utc}; +use sqlx::{Pool, Postgres, query, query_as}; use uuid::Uuid; +pub const SESSION_COOKIE_NAME: &str = "auth_session"; + +#[derive(Clone)] pub struct SessionService { - session_repo: SessionRepository, + pool: Pool, } impl SessionService { - pub fn new(session_repo: SessionRepository) -> Self { - Self { session_repo } + pub fn new(pool: Pool) -> Self { + Self { pool } } pub async fn create_session(&self, user_id: String) -> Result { @@ -19,16 +23,55 @@ impl SessionService { expires_at: Utc::now() + Duration::hours(24), }; - self.session_repo - .create(&session) - .await - .map_err(|_| ApiError::InternalServerError)?; + query!( + "INSERT INTO session (id, user_id, expires_at) VALUES ($1, $2, $3)", + session.id, + session.user_id, + session.expires_at + ) + .execute(&self.pool) + .await + .map_err(|_| ApiError::InternalServerError)?; Ok(session) } + pub async fn get_by_id(&self, session_id: &str) -> Result, ApiError> { + query_as!( + Session, + "SELECT id, user_id, expires_at FROM session WHERE id = $1", + session_id + ) + .fetch_optional(&self.pool) + .await + .map_err(|_| ApiError::InternalServerError) + } + + pub async fn find_valid_by_id(&self, session_id: &str) -> Result { + query_as!( + Session, + "SELECT id, user_id, expires_at FROM session WHERE id = $1 AND expires_at > $2", + session_id, + Utc::now() + ) + .fetch_one(&self.pool) + .await + .map_err(|err| match err { + sqlx::Error::RowNotFound => ApiError::Unauthorized, + _ => ApiError::InternalServerError, + }) + } + + pub async fn delete_session(&self, session_id: &str) -> Result<(), ApiError> { + query!("DELETE FROM session WHERE id = $1", session_id) + .execute(&self.pool) + .await + .map_err(|_| ApiError::InternalServerError)?; + Ok(()) + } + pub fn create_session_cookie(&self, session_id: &str) -> Cookie<'static> { - Cookie::build(("session_id", session_id.to_string())) + Cookie::build((SESSION_COOKIE_NAME, session_id.to_string())) .http_only(true) .secure(true) // Use HTTPS in production .same_site(SameSite::Lax) @@ -37,15 +80,8 @@ impl SessionService { .build() } - pub async fn delete_session(&self, session_id: &str) -> Result<(), ApiError> { - self.session_repo - .delete(session_id) - .await - .map_err(|_| ApiError::InternalServerError) - } - pub fn create_logout_cookie() -> Cookie<'static> { - Cookie::build(("session_id", "")) + Cookie::build((SESSION_COOKIE_NAME, "")) .http_only(true) .secure(true) .same_site(SameSite::Lax) diff --git a/apps/api/src/repositories/user.rs b/apps/api/src/services/user.rs similarity index 69% rename from apps/api/src/repositories/user.rs rename to apps/api/src/services/user.rs index b69b1dd5..85afa041 100644 --- a/apps/api/src/repositories/user.rs +++ b/apps/api/src/services/user.rs @@ -2,11 +2,12 @@ use sqlx::{Pool, Postgres, query_as}; use crate::{errors::ApiError, models::user::User}; -pub struct UserRepository { - pub pool: Pool, +#[derive(Clone)] +pub struct UserService { + pool: Pool, } -impl UserRepository { +impl UserService { pub fn new(pool: Pool) -> Self { Self { pool } } @@ -26,7 +27,7 @@ impl UserRepository { }) } - pub async fn get_by_feide_id(&self, feide_id: &str) -> Result, sqlx::Error> { + pub async fn get_by_feide_id(&self, feide_id: &str) -> Result, ApiError> { query_as!( User, "SELECT id, name, email, feide_id, role, additional_beers, alt_email, is_deleted @@ -35,9 +36,10 @@ impl UserRepository { ) .fetch_optional(&self.pool) .await + .map_err(|_| ApiError::InternalServerError) } - pub async fn create(&self, user: &User) -> Result<(), sqlx::Error> { + pub async fn create(&self, user: &User) -> Result<(), ApiError> { sqlx::query!( "INSERT INTO \"user\" (id, name, email, feide_id, role, additional_beers, alt_email, is_deleted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", @@ -51,8 +53,20 @@ impl UserRepository { user.is_deleted ) .execute(&self.pool) - .await?; + .await + .map_err(|_| ApiError::InternalServerError)?; Ok(()) } + + pub async fn list_active(&self) -> Result, ApiError> { + query_as!( + User, + "SELECT id, name, email, feide_id, role, additional_beers, alt_email, is_deleted + FROM \"user\" WHERE is_deleted = false" + ) + .fetch_all(&self.pool) + .await + .map_err(|_| ApiError::InternalServerError) + } } diff --git a/apps/api/src/state.rs b/apps/api/src/state.rs index 2210cc34..fb425f17 100644 --- a/apps/api/src/state.rs +++ b/apps/api/src/state.rs @@ -8,16 +8,10 @@ use s3::{Bucket, Region, creds::Credentials}; use crate::{ config::Config, providers::feide::FeideProvider, - repositories::{ - claimed_credit::ClaimedCreditRepository, contact_submission::ContactSubmissionRepository, - event::EventRepository, group::GroupRepository, image::ImageRepository, - invitation::InvitationRepository, notification::NotificationRepository, - producer::ProducerRepository, product::ProductRepository, - product_product_type::ProductProductTypeRepository, product_type::ProductTypeRepository, - session::SessionRepository, shift::ShiftRepository, user::UserRepository, - user_shift::UserShiftRepository, users_group::UsersGroupRepository, + services::{ + auth::AuthService, event::EventService, image::ImageService, invitation::InvitationService, + product::ProductService, session::SessionService, status::StatusService, user::UserService, }, - services::{auth::AuthService, session::SessionService, status::StatusService}, }; #[derive(Clone)] @@ -28,28 +22,15 @@ pub struct AppState { pub bucket: Box, pub redis: Arc, - // Repositories - pub claimed_credit_repo: Arc, - pub contact_submission_repo: Arc, - pub event_repo: Arc, - pub group_repo: Arc, - pub image_repo: Arc, - pub invitation_repo: Arc, - pub notification_repo: Arc, - pub producer_repo: Arc, - pub product_product_type_repo: Arc, - pub product_type_repo: Arc, - pub product_repo: Arc, - pub session_repo: Arc, - pub shift_repo: Arc, - pub user_shift_repo: Arc, - pub user_repo: Arc, - pub user_group_repo: Arc, - // Services pub auth_service: Arc, + pub invitation_service: Arc, pub session_service: Arc, pub status_service: Arc, + pub user_service: Arc, + pub image_service: Arc, + pub event_service: Arc, + pub product_service: Arc, // Providers pub feide_provider: Arc, @@ -77,7 +58,11 @@ impl AppState { } } - let key = Key::generate(); + let key = if let Ok(auth_secret) = std::env::var("AUTH_SECRET") { + Key::from(auth_secret.as_bytes()) + } else { + Key::generate() + }; let bucket_name = &config.s3_bucket; let region = Region::Custom { @@ -108,31 +93,19 @@ impl AppState { tracing::info!("Connected to Redis at {}", config.redis_url); - let claimed_credit_repo = Arc::new(ClaimedCreditRepository::new(pool.clone())); - let contact_submission_repo = Arc::new(ContactSubmissionRepository::new(pool.clone())); - let event_repo = Arc::new(EventRepository::new(pool.clone())); - let group_repo = Arc::new(GroupRepository::new(pool.clone())); - let image_repo = Arc::new(ImageRepository::new(pool.clone())); - let invitation_repo = Arc::new(InvitationRepository::new(pool.clone())); - let notification_repo = Arc::new(NotificationRepository::new(pool.clone())); - let producer_repo = Arc::new(ProducerRepository::new(pool.clone())); - let product_product_type_repo = Arc::new(ProductProductTypeRepository::new(pool.clone())); - let product_type_repo = Arc::new(ProductTypeRepository::new(pool.clone())); - let product_repo = Arc::new(ProductRepository::new(pool.clone())); - let session_repo = Arc::new(SessionRepository::new(pool.clone())); - let shift_repo = Arc::new(ShiftRepository::new(pool.clone())); - let user_shift_repo = Arc::new(UserShiftRepository::new(pool.clone())); - let user_repo = Arc::new(UserRepository::new(pool.clone())); - let user_group_repo = Arc::new(UsersGroupRepository::new(pool.clone())); - // Services + let user_service = Arc::new(UserService::new(pool.clone())); + let session_service = Arc::new(SessionService::new(pool.clone())); + let invitation_service = Arc::new(InvitationService::new(pool.clone())); + let image_service = Arc::new(ImageService::new(pool.clone())); + let event_service = Arc::new(EventService::new(pool.clone())); + let product_service = Arc::new(ProductService::new(pool.clone())); + let auth_service = Arc::new(AuthService::new( - SessionRepository::new(pool.clone()), - UserRepository::new(pool.clone()), + SessionService::new(pool.clone()), + UserService::new(pool.clone()), )); - let session_service = Arc::new(SessionService::new(SessionRepository::new(pool.clone()))); - let status_service = Arc::new(StatusService::new(redis.clone())); let feide_provider = Arc::new(FeideProvider::new( @@ -148,28 +121,15 @@ impl AppState { bucket, redis, - // Repositories - claimed_credit_repo, - contact_submission_repo, - event_repo, - group_repo, - image_repo, - invitation_repo, - notification_repo, - producer_repo, - product_product_type_repo, - product_type_repo, - product_repo, - session_repo, - shift_repo, - user_shift_repo, - user_repo, - user_group_repo, - // Services auth_service, + invitation_service, session_service, status_service, + user_service, + image_service, + event_service, + product_service, // Providers feide_provider, diff --git a/apps/www/.dev.vars.example b/apps/www/.env.example similarity index 72% rename from apps/www/.dev.vars.example rename to apps/www/.env.example index a1db4972..d5b0f536 100644 --- a/apps/www/.dev.vars.example +++ b/apps/www/.env.example @@ -5,3 +5,7 @@ RESEND_API_KEY=re_something FEIDE_CLIENT_ID="" FEIDE_CLIENT_SECRET="" FEIDE_REDIRECT_URI="http://localhost:5173/auth/feide/callback" + +# -- API +PUBLIC_API_URL=http://localhost:8000 +ADMIN_KEY=foobar \ No newline at end of file diff --git a/apps/www/package.json b/apps/www/package.json index 96ee1b3f..93ed1318 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -71,6 +71,7 @@ "date-fns": "4.1.0", "drizzle-orm": "0.44.4", "groq": "4.4.0", + "ky": "^1.9.0", "lucia": "3.2.2", "marked": "16.1.2", "nanoid": "5.1.5", diff --git a/apps/www/src/lib/api/bff/auth.ts b/apps/www/src/lib/api/bff/auth.ts new file mode 100644 index 00000000..41fb64ef --- /dev/null +++ b/apps/www/src/lib/api/bff/auth.ts @@ -0,0 +1,30 @@ +import { api } from '.'; + +export type ProfileResponse = { + additional_beers: number; + alt_email: string; + email: string; + feide_id: string; + id: string; + name: string; + role: string; +}; + +export async function getUserBySession(sessionId: string) { + return await api + .get('profile', { + headers: { + Cookie: `auth_session=${sessionId}` + } + }) + .json() + .catch(() => null); +} + +export async function logout() { + return await api + .post('auth/logout', { + throwHttpErrors: false + }) + .then((response) => response.ok); +} diff --git a/apps/www/src/lib/api/bff/index.ts b/apps/www/src/lib/api/bff/index.ts new file mode 100644 index 00000000..57b0c9fe --- /dev/null +++ b/apps/www/src/lib/api/bff/index.ts @@ -0,0 +1,9 @@ +import ky from 'ky'; + +export const api = ky.create({ + mode: 'cors', + credentials: 'include', + prefixUrl: 'http://localhost:8000' +}); + +export * from './auth'; diff --git a/apps/www/src/lib/api/index.ts b/apps/www/src/lib/api/index.ts new file mode 100644 index 00000000..0f5e55c4 --- /dev/null +++ b/apps/www/src/lib/api/index.ts @@ -0,0 +1 @@ +export * as api from './bff'; diff --git a/apps/www/src/lib/api/stock.ts b/apps/www/src/lib/api/stock.ts deleted file mode 100644 index 3f1243b0..00000000 --- a/apps/www/src/lib/api/stock.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { dev } from '$app/environment'; - -const FRONTLINE_PROXY = dev ? 'http://localhost:8787' : 'https://frontline.programmer.bar'; - -export const getStock = async (sku: string | null) => { - if (!sku) return null; - - try { - const resp = await fetch(`${FRONTLINE_PROXY}/product/${sku}`); - - if (resp.status === 404) { - return null; - } - - const data = (await resp.json()) as { stock: string }; - return Number(data.stock); - } catch (e) { - console.error(e); - return null; - } -}; diff --git a/apps/www/src/routes/booking/+server.ts b/apps/www/src/routes/(app)/booking/+server.ts similarity index 100% rename from apps/www/src/routes/booking/+server.ts rename to apps/www/src/routes/(app)/booking/+server.ts diff --git a/apps/www/src/routes/cms/+server.ts b/apps/www/src/routes/(app)/cms/+server.ts similarity index 100% rename from apps/www/src/routes/cms/+server.ts rename to apps/www/src/routes/(app)/cms/+server.ts diff --git a/apps/www/src/routes/kontakt-oss/+page.server.ts b/apps/www/src/routes/(app)/kontakt-oss/+page.server.ts similarity index 100% rename from apps/www/src/routes/kontakt-oss/+page.server.ts rename to apps/www/src/routes/(app)/kontakt-oss/+page.server.ts diff --git a/apps/www/src/routes/api/events/+server.ts b/apps/www/src/routes/(portal)/api/events/+server.ts similarity index 100% rename from apps/www/src/routes/api/events/+server.ts rename to apps/www/src/routes/(portal)/api/events/+server.ts diff --git a/apps/www/src/routes/api/images/[id]/+server.ts b/apps/www/src/routes/(portal)/api/images/[id]/+server.ts similarity index 100% rename from apps/www/src/routes/api/images/[id]/+server.ts rename to apps/www/src/routes/(portal)/api/images/[id]/+server.ts diff --git a/apps/www/src/routes/api/invitations/+server.ts b/apps/www/src/routes/(portal)/api/invitations/+server.ts similarity index 100% rename from apps/www/src/routes/api/invitations/+server.ts rename to apps/www/src/routes/(portal)/api/invitations/+server.ts diff --git a/apps/www/src/routes/api/status/+server.ts b/apps/www/src/routes/(portal)/api/status/+server.ts similarity index 100% rename from apps/www/src/routes/api/status/+server.ts rename to apps/www/src/routes/(portal)/api/status/+server.ts diff --git a/apps/www/src/routes/api/upload/+server.ts b/apps/www/src/routes/(portal)/api/upload/+server.ts similarity index 100% rename from apps/www/src/routes/api/upload/+server.ts rename to apps/www/src/routes/(portal)/api/upload/+server.ts diff --git a/apps/www/src/routes/portal/+layout.server.ts b/apps/www/src/routes/(portal)/portal/+layout.server.ts similarity index 100% rename from apps/www/src/routes/portal/+layout.server.ts rename to apps/www/src/routes/(portal)/portal/+layout.server.ts diff --git a/apps/www/src/routes/portal/+layout.svelte b/apps/www/src/routes/(portal)/portal/+layout.svelte similarity index 100% rename from apps/www/src/routes/portal/+layout.svelte rename to apps/www/src/routes/(portal)/portal/+layout.svelte diff --git a/apps/www/src/routes/portal/+page.server.ts b/apps/www/src/routes/(portal)/portal/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/+page.server.ts rename to apps/www/src/routes/(portal)/portal/+page.server.ts diff --git a/apps/www/src/routes/portal/+page.svelte b/apps/www/src/routes/(portal)/portal/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/+page.svelte rename to apps/www/src/routes/(portal)/portal/+page.svelte diff --git a/apps/www/src/routes/portal/admin/+layout.svelte b/apps/www/src/routes/(portal)/portal/admin/+layout.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/+layout.svelte rename to apps/www/src/routes/(portal)/portal/admin/+layout.svelte diff --git a/apps/www/src/routes/portal/admin/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/producers/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/producers/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/producers/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/producers/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/producers/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/producers/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/producers/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/producers/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/producers/[id]/edit/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/producers/[id]/edit/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/producers/[id]/edit/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/producers/[id]/edit/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/producers/[id]/edit/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/producers/[id]/edit/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/producers/[id]/edit/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/producers/[id]/edit/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/producers/new/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/producers/new/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/producers/new/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/producers/new/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/producers/new/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/producers/new/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/producers/new/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/producers/new/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/product-types/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/product-types/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/product-types/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/product-types/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/product-types/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/product-types/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/product-types/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/product-types/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/product-types/[id]/edit/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/product-types/[id]/edit/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/product-types/[id]/edit/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/product-types/[id]/edit/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/product-types/[id]/edit/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/product-types/[id]/edit/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/product-types/[id]/edit/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/product-types/[id]/edit/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/product-types/new/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/product-types/new/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/product-types/new/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/product-types/new/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/product-types/new/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/product-types/new/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/product-types/new/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/product-types/new/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/products/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/products/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/products/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/products/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/products/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/products/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/products/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/products/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/products/[id]/edit/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/products/[id]/edit/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/products/[id]/edit/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/products/[id]/edit/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/products/[id]/edit/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/products/[id]/edit/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/products/[id]/edit/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/products/[id]/edit/+page.svelte diff --git a/apps/www/src/routes/portal/admin/cms/products/new/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/cms/products/new/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/cms/products/new/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/cms/products/new/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/cms/products/new/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/cms/products/new/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/cms/products/new/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/cms/products/new/+page.svelte diff --git a/apps/www/src/routes/portal/admin/pending-applications/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/pending-applications/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/pending-applications/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/pending-applications/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/pending-applications/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/pending-applications/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/pending-applications/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/pending-applications/+page.svelte diff --git a/apps/www/src/routes/portal/admin/user/[id]/+page.server.ts b/apps/www/src/routes/(portal)/portal/admin/user/[id]/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/admin/user/[id]/+page.server.ts rename to apps/www/src/routes/(portal)/portal/admin/user/[id]/+page.server.ts diff --git a/apps/www/src/routes/portal/admin/user/[id]/+page.svelte b/apps/www/src/routes/(portal)/portal/admin/user/[id]/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/admin/user/[id]/+page.svelte rename to apps/www/src/routes/(portal)/portal/admin/user/[id]/+page.svelte diff --git a/apps/www/src/routes/portal/arrangementer/+page.server.ts b/apps/www/src/routes/(portal)/portal/arrangementer/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/arrangementer/+page.server.ts rename to apps/www/src/routes/(portal)/portal/arrangementer/+page.server.ts diff --git a/apps/www/src/routes/portal/arrangementer/+page.svelte b/apps/www/src/routes/(portal)/portal/arrangementer/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/arrangementer/+page.svelte rename to apps/www/src/routes/(portal)/portal/arrangementer/+page.svelte diff --git a/apps/www/src/routes/portal/arrangementer/[id]/+page.server.ts b/apps/www/src/routes/(portal)/portal/arrangementer/[id]/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/arrangementer/[id]/+page.server.ts rename to apps/www/src/routes/(portal)/portal/arrangementer/[id]/+page.server.ts diff --git a/apps/www/src/routes/portal/arrangementer/[id]/+page.svelte b/apps/www/src/routes/(portal)/portal/arrangementer/[id]/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/arrangementer/[id]/+page.svelte rename to apps/www/src/routes/(portal)/portal/arrangementer/[id]/+page.svelte diff --git a/apps/www/src/routes/portal/arrangementer/[id]/edit/+page.server.ts b/apps/www/src/routes/(portal)/portal/arrangementer/[id]/edit/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/arrangementer/[id]/edit/+page.server.ts rename to apps/www/src/routes/(portal)/portal/arrangementer/[id]/edit/+page.server.ts diff --git a/apps/www/src/routes/portal/arrangementer/[id]/edit/+page.svelte b/apps/www/src/routes/(portal)/portal/arrangementer/[id]/edit/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/arrangementer/[id]/edit/+page.svelte rename to apps/www/src/routes/(portal)/portal/arrangementer/[id]/edit/+page.svelte diff --git a/apps/www/src/routes/portal/arrangementer/ny/+page.server.ts b/apps/www/src/routes/(portal)/portal/arrangementer/ny/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/arrangementer/ny/+page.server.ts rename to apps/www/src/routes/(portal)/portal/arrangementer/ny/+page.server.ts diff --git a/apps/www/src/routes/portal/arrangementer/ny/+page.svelte b/apps/www/src/routes/(portal)/portal/arrangementer/ny/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/arrangementer/ny/+page.svelte rename to apps/www/src/routes/(portal)/portal/arrangementer/ny/+page.svelte diff --git a/apps/www/src/routes/portal/brukere/+page.server.ts b/apps/www/src/routes/(portal)/portal/brukere/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/brukere/+page.server.ts rename to apps/www/src/routes/(portal)/portal/brukere/+page.server.ts diff --git a/apps/www/src/routes/portal/brukere/+page.svelte b/apps/www/src/routes/(portal)/portal/brukere/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/brukere/+page.svelte rename to apps/www/src/routes/(portal)/portal/brukere/+page.svelte diff --git a/apps/www/src/routes/portal/claim-beer/+page.server.ts b/apps/www/src/routes/(portal)/portal/claim-beer/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/claim-beer/+page.server.ts rename to apps/www/src/routes/(portal)/portal/claim-beer/+page.server.ts diff --git a/apps/www/src/routes/portal/claim-beer/+page.svelte b/apps/www/src/routes/(portal)/portal/claim-beer/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/claim-beer/+page.svelte rename to apps/www/src/routes/(portal)/portal/claim-beer/+page.svelte diff --git a/apps/www/src/routes/portal/notifikasjoner/+page.server.ts b/apps/www/src/routes/(portal)/portal/notifikasjoner/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/notifikasjoner/+page.server.ts rename to apps/www/src/routes/(portal)/portal/notifikasjoner/+page.server.ts diff --git a/apps/www/src/routes/portal/notifikasjoner/+page.svelte b/apps/www/src/routes/(portal)/portal/notifikasjoner/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/notifikasjoner/+page.svelte rename to apps/www/src/routes/(portal)/portal/notifikasjoner/+page.svelte diff --git a/apps/www/src/routes/portal/profil/+page.server.ts b/apps/www/src/routes/(portal)/portal/profil/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/profil/+page.server.ts rename to apps/www/src/routes/(portal)/portal/profil/+page.server.ts diff --git a/apps/www/src/routes/portal/profil/+page.svelte b/apps/www/src/routes/(portal)/portal/profil/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/profil/+page.svelte rename to apps/www/src/routes/(portal)/portal/profil/+page.svelte diff --git a/apps/www/src/routes/portal/status/+page.server.ts b/apps/www/src/routes/(portal)/portal/status/+page.server.ts similarity index 100% rename from apps/www/src/routes/portal/status/+page.server.ts rename to apps/www/src/routes/(portal)/portal/status/+page.server.ts diff --git a/apps/www/src/routes/portal/status/+page.svelte b/apps/www/src/routes/(portal)/portal/status/+page.svelte similarity index 100% rename from apps/www/src/routes/portal/status/+page.svelte rename to apps/www/src/routes/(portal)/portal/status/+page.svelte diff --git a/apps/www/src/routes/auth/feide/+server.ts b/apps/www/src/routes/auth/feide/+server.ts deleted file mode 100644 index c2278d45..00000000 --- a/apps/www/src/routes/auth/feide/+server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { dev } from '$app/environment'; -import { generateState } from '$lib/auth/providers/oauth2'; -import { COOKIE_NAME_FEIDE_OAUTH_STATE } from '$lib/constants'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = ({ locals, cookies }) => { - const state = generateState(); - const url = locals.feideProvider.createAuthorizationURL(state, []); - - cookies.set(COOKIE_NAME_FEIDE_OAUTH_STATE, state, { - path: '/', - httpOnly: true, - secure: !dev, - maxAge: 60 * 10 - }); - - return new Response(null, { - status: 302, - headers: { - location: url.toString() - } - }); -}; diff --git a/apps/www/src/routes/auth/feide/callback/+server.ts b/apps/www/src/routes/auth/feide/callback/+server.ts deleted file mode 100644 index c6b322e1..00000000 --- a/apps/www/src/routes/auth/feide/callback/+server.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { getFeideUser } from '$lib/auth/providers/feide'; -import { nanoid } from 'nanoid'; -import type { RequestHandler } from './$types'; -import { setSessionCookie } from '$lib/auth/cookies'; -import { - COOKIE_NAME_FEIDE_OAUTH_STATE, - COOKIE_NAME_FROM, - ERROR_SEARCH_PARAM_ALREADY_REGISTERED, - COOKIE_VALUE_BLI_FRIVILLIG, - COOKIE_NAME_CODE, - COOKIE_NAME_STATE -} from '$lib/constants'; - -export const GET: RequestHandler = async ({ locals, cookies, url }) => { - const state = url.searchParams.get(COOKIE_NAME_STATE); - const code = url.searchParams.get(COOKIE_NAME_CODE); - const storedState = cookies.get(COOKIE_NAME_FEIDE_OAUTH_STATE); - - if (!state || !code || !storedState || state !== storedState) { - return new Response('Invalid state', { - status: 400 - }); - } - - const tokens = await locals.feideProvider.validateAuthorizationCode(code); - const feideUser = await getFeideUser(tokens.accessToken()); - const existingUser = await locals.userService.findByFeideId(feideUser.id); - - // Check if user was deleted - const deletedUser = await locals.userService.findByFeideIdIncludeDeleted(feideUser.id); - if (deletedUser && deletedUser.isDeleted) { - return new Response('Du har blitt slettet fra systemet', { - status: 403 - }); - } - - const from = cookies.get(COOKIE_NAME_FROM); - if (from === COOKIE_VALUE_BLI_FRIVILLIG) { - cookies.delete(COOKIE_NAME_FROM, { path: '/' }); - - if (existingUser) { - return new Response(null, { - status: 302, - headers: { - location: `/bli-frivillig?error=${ERROR_SEARCH_PARAM_ALREADY_REGISTERED}` - } - }); - } - - const existingApplication = await locals.pendingApplicationService.findByFeideId(feideUser.id); - if (existingApplication) { - return new Response(null, { - status: 302, - headers: { - location: `/bli-frivillig?error=${ERROR_SEARCH_PARAM_ALREADY_REGISTERED}` - } - }); - } - - await locals.pendingApplicationService.create({ - name: feideUser.username, - email: feideUser.email, - feideId: feideUser.id - }); - - await locals.emailService.sendVolunteerRequestEmail({ - name: feideUser.username, - email: feideUser.email - }); - - return new Response(null, { - status: 302, - headers: { - location: '/bli-frivillig?success=true' - } - }); - } - - if (existingUser) { - const session = await locals.auth.createSession(existingUser.id, {}); - const sessionCookie = locals.auth.createSessionCookie(session.id); - setSessionCookie(cookies, locals.auth.sessionCookieName, sessionCookie); - - return new Response(null, { - status: 302, - headers: { - location: '/' - } - }); - } - - const [invitation, error] = await locals.invitationService.findValidInvitationByEmail( - feideUser.email.toLowerCase() - ); - - if (error !== null) { - return new Response(error, { - status: 403 - }); - } - - await locals.invitationService.claim(invitation.id); - - const userId = nanoid(); - await locals.userService.create({ - id: userId, - name: feideUser.username, - email: feideUser.email, - feideId: feideUser.id - }); - - const session = await locals.auth.createSession(userId, {}); - const sessionCookie = locals.auth.createSessionCookie(session.id); - setSessionCookie(cookies, locals.auth.sessionCookieName, sessionCookie); - - return new Response(null, { - status: 302, - headers: { - location: '/portal' - } - }); -}; diff --git a/apps/www/src/routes/auth/logg-ut/+page.server.ts b/apps/www/src/routes/auth/logg-ut/+page.server.ts deleted file mode 100644 index 9ecb1a14..00000000 --- a/apps/www/src/routes/auth/logg-ut/+page.server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { dev } from '$app/environment'; -import type { Actions } from './$types'; - -export const actions: Actions = { - default: async ({ locals, cookies }) => { - if (!locals.session) { - return { success: true }; - } - - await locals.auth.invalidateSession(locals.session.id); - cookies.delete(locals.auth.sessionCookieName, { - path: '/', - httpOnly: true, - secure: !dev - }); - - return { success: true }; - } -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f363ee46..fd21e71a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: groq: specifier: 4.4.0 version: 4.4.0 + ky: + specifier: ^1.9.0 + version: 1.9.0 lucia: specifier: 3.2.2 version: 3.2.2 @@ -11558,6 +11561,11 @@ packages: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} dev: true + /ky@1.9.0: + resolution: {integrity: sha512-NgBeR/cu7kuC4BAeF1rnXhfoI2uQ9RBe8zl5vo87ASsf1iIQoCeOxyt6Io6K4Ki++5ItCavXAtbEWWCGFciQ6g==} + engines: {node: '>=18'} + dev: false + /lambda-runtimes@2.0.5: resolution: {integrity: sha512-6BoLX9xuvr+B/f05MOhJnzRdF8Za5YYh82n45ndun9EU3uhJv9kIwnYrOrvuA7MoGwZgCMI7RUhBRzfw/l63SQ==} engines: {node: '>=14'}