adding api
This commit is contained in:
parent
5a7cfd8f9a
commit
88d7738409
15 changed files with 4197 additions and 150 deletions
5
api/.gitignore
vendored
Normal file
5
api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Generated build artifacts
|
||||||
|
/target
|
||||||
|
|
||||||
|
# Rust backup files
|
||||||
|
**/*.rs.bk
|
||||||
3066
api/Cargo.lock
generated
Normal file
3066
api/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
api/Cargo.toml
Normal file
17
api/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
bcrypt = "0.15"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
rocket = { version = "0.5.0", features = ["json"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "macros", "migrate"] }
|
||||||
|
thiserror = "1"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
1
api/migrations/20250101000000_enable_pg_trgm.sql
Normal file
1
api/migrations/20250101000000_enable_pg_trgm.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
6
api/migrations/20250101000500_create_users.sql
Normal file
6
api/migrations/20250101000500_create_users.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
11
api/migrations/20250101001000_create_persons.sql
Normal file
11
api/migrations/20250101001000_create_persons.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS persons (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
age INTEGER NOT NULL,
|
||||||
|
phone_number TEXT NOT NULL,
|
||||||
|
checked_in BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
inside BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persons_name_trgm ON persons USING GIN (name gin_trgm_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persons_phone_number ON persons (phone_number);
|
||||||
120
api/src/auth.rs
Normal file
120
api/src/auth.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::AppState;
|
||||||
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use rocket::http::CookieJar;
|
||||||
|
use rocket::request::{FromRequest, Outcome};
|
||||||
|
use rocket::{async_trait, http::Status, Request, State};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: i32,
|
||||||
|
pub username: String,
|
||||||
|
pub exp: usize,
|
||||||
|
pub iat: usize,
|
||||||
|
pub iss: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub user_id: i32,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_token(state: &AppState, user_id: i32, username: &str) -> Result<String, ApiError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let exp = now
|
||||||
|
.checked_add_signed(chrono::Duration::seconds(state.jwt_ttl_seconds))
|
||||||
|
.ok_or_else(|| ApiError::internal("Kunde inte skapa utgångstid för token."))?;
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id,
|
||||||
|
username: username.to_string(),
|
||||||
|
exp: exp.timestamp() as usize,
|
||||||
|
iat: now.timestamp() as usize,
|
||||||
|
iss: state.jwt_issuer.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Header::new(Algorithm::HS256);
|
||||||
|
encode(
|
||||||
|
&header,
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("Failed to encode JWT: {e:?}");
|
||||||
|
ApiError::internal("Misslyckades att skapa token.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for AuthUser {
|
||||||
|
type Error = ApiError;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
let state_outcome = request.guard::<&State<AppState>>().await;
|
||||||
|
let state = match state_outcome {
|
||||||
|
Outcome::Success(state) => state,
|
||||||
|
Outcome::Error((status, _)) => {
|
||||||
|
return Outcome::Error((
|
||||||
|
status,
|
||||||
|
ApiError::internal("Kunde inte läsa applikationsstatus."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Outcome::Forward(_) => {
|
||||||
|
return Outcome::Error((
|
||||||
|
Status::InternalServerError,
|
||||||
|
ApiError::internal("Kunde inte läsa applikationsstatus."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let jar_outcome = request.guard::<&CookieJar>().await;
|
||||||
|
let jar = match jar_outcome {
|
||||||
|
Outcome::Success(jar) => jar,
|
||||||
|
Outcome::Error((status, _)) => {
|
||||||
|
return Outcome::Error((status, ApiError::internal("Kunde inte läsa cookies.")));
|
||||||
|
}
|
||||||
|
Outcome::Forward(_) => {
|
||||||
|
return Outcome::Error((
|
||||||
|
Status::Unauthorized,
|
||||||
|
ApiError::unauthorized("Ingen åtkomsttoken hittades."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let cookie = match jar.get(&state.cookie_name) {
|
||||||
|
Some(cookie) => cookie,
|
||||||
|
None => {
|
||||||
|
return Outcome::Error((
|
||||||
|
Status::Unauthorized,
|
||||||
|
ApiError::unauthorized("Inloggning krävs."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = cookie.value();
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.set_issuer(&[state.jwt_issuer.clone()]);
|
||||||
|
|
||||||
|
match decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||||
|
&validation,
|
||||||
|
) {
|
||||||
|
Ok(data) => Outcome::Success(AuthUser {
|
||||||
|
user_id: data.claims.sub,
|
||||||
|
username: data.claims.username,
|
||||||
|
}),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("JWT decode error: {err:?}");
|
||||||
|
Outcome::Error((
|
||||||
|
Status::Unauthorized,
|
||||||
|
ApiError::unauthorized("Ogiltig eller utgången session."),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
api/src/config.rs
Normal file
64
api/src/config.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("Saknar miljövariabel: {0}")]
|
||||||
|
MissingEnv(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub database_url: String,
|
||||||
|
pub database_max_connections: u32,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub jwt_issuer: String,
|
||||||
|
pub jwt_ttl_seconds: i64,
|
||||||
|
pub cookie_name: String,
|
||||||
|
pub cookie_secure: bool,
|
||||||
|
pub admin_username: String,
|
||||||
|
pub admin_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let database_url = env::var("DATABASE_URL")
|
||||||
|
.map_err(|_| ConfigError::MissingEnv("DATABASE_URL".to_string()))?;
|
||||||
|
let jwt_secret = env::var("JWT_SECRET")
|
||||||
|
.map_err(|_| ConfigError::MissingEnv("JWT_SECRET".to_string()))?;
|
||||||
|
|
||||||
|
let database_max_connections = env::var("DATABASE_MAX_CONNECTIONS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.unwrap_or(10);
|
||||||
|
|
||||||
|
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "vbytes-api".to_string());
|
||||||
|
let jwt_ttl_seconds = env::var("JWT_TTL_SECONDS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<i64>().ok())
|
||||||
|
.unwrap_or(24 * 60 * 60);
|
||||||
|
|
||||||
|
let cookie_name = env::var("JWT_COOKIE_NAME").unwrap_or_else(|_| "auth_token".to_string());
|
||||||
|
let cookie_secure = env::var("JWT_COOKIE_SECURE")
|
||||||
|
.ok()
|
||||||
|
.map(|v| v.to_lowercase())
|
||||||
|
.map(|v| matches!(v.as_str(), "true" | "1" | "yes"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let admin_username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string());
|
||||||
|
let admin_password = env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".to_string());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
database_url,
|
||||||
|
database_max_connections,
|
||||||
|
jwt_secret,
|
||||||
|
jwt_issuer,
|
||||||
|
jwt_ttl_seconds,
|
||||||
|
cookie_name,
|
||||||
|
cookie_secure,
|
||||||
|
admin_username,
|
||||||
|
admin_password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
79
api/src/error.rs
Normal file
79
api/src/error.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use rocket::http::{ContentType, Status};
|
||||||
|
use rocket::response::{Responder, Response};
|
||||||
|
use rocket::Request;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub status: Status,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
pub fn new(status: Status, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(Status::Unauthorized, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(Status::BadRequest, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(Status::NotFound, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(Status::InternalServerError, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'r> Responder<'r, 'static> for ApiError {
|
||||||
|
fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> {
|
||||||
|
let payload = ErrorResponse {
|
||||||
|
message: self.message,
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = serde_json::to_string(&payload)
|
||||||
|
.unwrap_or_else(|_| "{\"message\":\"Ett oväntat fel inträffade.\"}".to_string());
|
||||||
|
|
||||||
|
let mut response = Response::new();
|
||||||
|
response.set_status(self.status);
|
||||||
|
response.set_header(ContentType::JSON);
|
||||||
|
response.set_sized_body(body.len(), Cursor::new(body));
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for ApiError {
|
||||||
|
fn from(err: sqlx::Error) -> Self {
|
||||||
|
eprintln!("Database error: {err:?}");
|
||||||
|
ApiError::internal("Databasfel.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<bcrypt::BcryptError> for ApiError {
|
||||||
|
fn from(err: bcrypt::BcryptError) -> Self {
|
||||||
|
eprintln!("bcrypt error: {err:?}");
|
||||||
|
ApiError::internal("Misslyckades att bearbeta lösenord.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<jsonwebtoken::errors::Error> for ApiError {
|
||||||
|
fn from(err: jsonwebtoken::errors::Error) -> Self {
|
||||||
|
eprintln!("JWT error: {err:?}");
|
||||||
|
ApiError::internal("Tokenfel.")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
api/src/lib.rs
Normal file
1
api/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
464
api/src/main.rs
Normal file
464
api/src/main.rs
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod models;
|
||||||
|
mod seed;
|
||||||
|
|
||||||
|
use auth::{generate_token, AuthUser};
|
||||||
|
use config::AppConfig;
|
||||||
|
use error::ApiError;
|
||||||
|
use models::{
|
||||||
|
LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse,
|
||||||
|
PersonsResponse, User,
|
||||||
|
};
|
||||||
|
use rocket::http::{Cookie, CookieJar, SameSite, Status};
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::time::Duration;
|
||||||
|
use rocket::{get, post, routes, State};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use sqlx::{PgPool, QueryBuilder};
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: PgPool,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub jwt_issuer: String,
|
||||||
|
pub jwt_ttl_seconds: i64,
|
||||||
|
pub cookie_name: String,
|
||||||
|
pub cookie_secure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::main]
|
||||||
|
async fn main() -> Result<(), rocket::Error> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let config = AppConfig::from_env().expect("Kunde inte läsa konfiguration");
|
||||||
|
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(config.database_max_connections)
|
||||||
|
.connect(&config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("Kunde inte ansluta till databasen");
|
||||||
|
|
||||||
|
sqlx::migrate!()
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Misslyckades att köra migrationer");
|
||||||
|
|
||||||
|
seed::run(&pool, &config.admin_username, &config.admin_password)
|
||||||
|
.await
|
||||||
|
.expect("Misslyckades att seed:a databasen");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
jwt_secret: config.jwt_secret,
|
||||||
|
jwt_issuer: config.jwt_issuer,
|
||||||
|
jwt_ttl_seconds: config.jwt_ttl_seconds,
|
||||||
|
cookie_name: config.cookie_name,
|
||||||
|
cookie_secure: config.cookie_secure,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rocket = rocket::build()
|
||||||
|
.manage(state)
|
||||||
|
.mount("/", routes![healthz, login, logout])
|
||||||
|
.mount(
|
||||||
|
"/persons",
|
||||||
|
routes![
|
||||||
|
search_persons,
|
||||||
|
list_checked_in,
|
||||||
|
checkin_person,
|
||||||
|
checkout_person,
|
||||||
|
mark_inside,
|
||||||
|
mark_outside,
|
||||||
|
create_person
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
rocket.launch().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/healthz")]
|
||||||
|
fn healthz() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/login", data = "<payload>")]
|
||||||
|
async fn login(
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
state: &State<AppState>,
|
||||||
|
payload: Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResponse>, ApiError> {
|
||||||
|
let username = payload.username.trim();
|
||||||
|
let password = payload.password.trim();
|
||||||
|
|
||||||
|
if username.is_empty() || password.is_empty() {
|
||||||
|
return Err(ApiError::bad_request("Användarnamn och lösenord krävs."));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = sqlx::query_as::<_, User>(
|
||||||
|
r#"
|
||||||
|
SELECT id, username, password_hash, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE username = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::unauthorized("Felaktigt användarnamn eller lösenord."))?;
|
||||||
|
|
||||||
|
let valid = bcrypt::verify(password, &user.password_hash)
|
||||||
|
.map_err(|_| ApiError::unauthorized("Felaktigt användarnamn eller lösenord."))?;
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return Err(ApiError::unauthorized(
|
||||||
|
"Felaktigt användarnamn eller lösenord.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = generate_token(state.inner(), user.id, &user.username)?;
|
||||||
|
|
||||||
|
let mut cookie = Cookie::new(state.cookie_name.clone(), token);
|
||||||
|
cookie.set_http_only(true);
|
||||||
|
cookie.set_same_site(Some(SameSite::Lax));
|
||||||
|
cookie.set_path("/".to_string());
|
||||||
|
cookie.set_max_age(Duration::seconds(state.inner().jwt_ttl_seconds));
|
||||||
|
if state.cookie_secure {
|
||||||
|
cookie.set_secure(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.add(cookie);
|
||||||
|
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
username: user.username,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/logout")]
|
||||||
|
fn logout(cookies: &CookieJar<'_>, state: &State<AppState>) -> Status {
|
||||||
|
let mut cookie = Cookie::new(state.cookie_name.clone(), String::new());
|
||||||
|
cookie.set_http_only(true);
|
||||||
|
cookie.set_same_site(Some(SameSite::Lax));
|
||||||
|
cookie.set_path("/".to_string());
|
||||||
|
if state.cookie_secure {
|
||||||
|
cookie.set_secure(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.remove(cookie);
|
||||||
|
Status::NoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/search?<q>")]
|
||||||
|
async fn search_persons(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &State<AppState>,
|
||||||
|
q: &str,
|
||||||
|
) -> Result<Json<PersonsResponse>, ApiError> {
|
||||||
|
let query = q.trim();
|
||||||
|
if query.is_empty() {
|
||||||
|
return Err(ApiError::bad_request("Söktext krävs."));
|
||||||
|
}
|
||||||
|
|
||||||
|
let like_pattern = format!("%{}%", query);
|
||||||
|
let id_value = query.parse::<i32>().ok();
|
||||||
|
|
||||||
|
let persons = if let Some(id) = id_value {
|
||||||
|
sqlx::query_as::<_, Person>(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, age, phone_number, checked_in, inside
|
||||||
|
FROM persons
|
||||||
|
WHERE name ILIKE $1
|
||||||
|
OR phone_number ILIKE $1
|
||||||
|
OR id = $2
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 100
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&like_pattern)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, Person>(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, age, phone_number, checked_in, inside
|
||||||
|
FROM persons
|
||||||
|
WHERE name ILIKE $1
|
||||||
|
OR phone_number ILIKE $1
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 100
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&like_pattern)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let persons = persons.into_iter().map(PersonResponse::from).collect();
|
||||||
|
Ok(Json(PersonsResponse { persons }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/checked-in?<q>&<status>&<checked>")]
|
||||||
|
async fn list_checked_in(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &State<AppState>,
|
||||||
|
q: Option<&str>,
|
||||||
|
status: Option<&str>,
|
||||||
|
checked: Option<&str>,
|
||||||
|
) -> Result<Json<PersonsResponse>, ApiError> {
|
||||||
|
let inside_filter = match status.map(|s| s.trim().to_lowercase()) {
|
||||||
|
Some(value) if value.is_empty() || value == "all" => None,
|
||||||
|
Some(value) if value == "inside" => Some(true),
|
||||||
|
Some(value) if value == "outside" => Some(false),
|
||||||
|
Some(_) => return Err(ApiError::bad_request("Ogiltigt statusvärde.")),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let checked_filter = match checked.map(|s| s.trim().to_lowercase()) {
|
||||||
|
Some(value) if value.is_empty() || value == "all" => None,
|
||||||
|
Some(value) if value == "true" || value == "checked" || value == "in" => Some(true),
|
||||||
|
Some(value) if value == "false" || value == "unchecked" || value == "out" => Some(false),
|
||||||
|
Some(_) => return Err(ApiError::bad_request("Ogiltigt filter för incheckning.")),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_term = q
|
||||||
|
.map(|raw| raw.trim())
|
||||||
|
.filter(|trimmed| !trimmed.is_empty())
|
||||||
|
.map(|trimmed| trimmed.to_string());
|
||||||
|
let like_pattern = search_term.as_ref().map(|s| format!("%{}%", s));
|
||||||
|
let id_value = search_term.as_ref().and_then(|s| s.parse::<i32>().ok());
|
||||||
|
|
||||||
|
let mut qb = QueryBuilder::<sqlx::Postgres>::new(
|
||||||
|
"SELECT id, name, age, phone_number, checked_in, inside FROM persons",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut first_condition = true;
|
||||||
|
let mut append_condition = |qb: &mut QueryBuilder<sqlx::Postgres>| {
|
||||||
|
if first_condition {
|
||||||
|
qb.push(" WHERE ");
|
||||||
|
first_condition = false;
|
||||||
|
} else {
|
||||||
|
qb.push(" AND ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(checked) = checked_filter {
|
||||||
|
append_condition(&mut qb);
|
||||||
|
qb.push("checked_in = ").push_bind(checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(inside) = inside_filter {
|
||||||
|
append_condition(&mut qb);
|
||||||
|
qb.push("inside = ").push_bind(inside);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(like) = like_pattern.as_ref() {
|
||||||
|
append_condition(&mut qb);
|
||||||
|
qb.push("(");
|
||||||
|
qb.push("name ILIKE ").push_bind(like);
|
||||||
|
qb.push(" OR phone_number ILIKE ").push_bind(like);
|
||||||
|
if let Some(id) = id_value {
|
||||||
|
qb.push(" OR id = ").push_bind(id);
|
||||||
|
}
|
||||||
|
qb.push(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = id_value {
|
||||||
|
qb.push(" ORDER BY CASE WHEN id = ")
|
||||||
|
.push_bind(id)
|
||||||
|
.push(" THEN 0 ELSE 1 END, id, name");
|
||||||
|
} else {
|
||||||
|
qb.push(" ORDER BY id, name");
|
||||||
|
}
|
||||||
|
if like_pattern.is_some() {
|
||||||
|
qb.push(" LIMIT 1000");
|
||||||
|
}
|
||||||
|
|
||||||
|
let persons = qb.build_query_as::<Person>().fetch_all(&state.db).await?;
|
||||||
|
|
||||||
|
let persons = persons.into_iter().map(PersonResponse::from).collect();
|
||||||
|
Ok(Json(PersonsResponse { persons }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/<id>/checkin")]
|
||||||
|
async fn checkin_person(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &State<AppState>,
|
||||||
|
id: i32,
|
||||||
|
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||||
|
update_checked_in(state, id, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/<id>/checkout")]
|
||||||
|
async fn checkout_person(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &State<AppState>,
|
||||||
|
id: i32,
|
||||||
|
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||||
|
update_checked_in(state, id, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/<id>/inside")]
|
||||||
|
async fn mark_inside(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &State<AppState>,
|
||||||
|
id: i32,
|
||||||
|
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||||
|
update_inside(state, id, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/<id>/outside")]
|
||||||
|
async fn mark_outside(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &State<AppState>,
|
||||||
|
id: i32,
|
||||||
|
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||||
|
update_inside(state, id, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/", data = "<payload>")]
|
||||||
|
async fn create_person(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &State<AppState>,
|
||||||
|
payload: Json<NewPersonRequest>,
|
||||||
|
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||||
|
let name = payload.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ApiError::bad_request("Namn får inte vara tomt."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.age < 0 {
|
||||||
|
return Err(ApiError::bad_request("Ålder måste vara noll eller högre."));
|
||||||
|
}
|
||||||
|
|
||||||
|
let phone_number = payload.phone_number.trim();
|
||||||
|
if phone_number.is_empty() {
|
||||||
|
return Err(ApiError::bad_request("Telefonnummer krävs."));
|
||||||
|
}
|
||||||
|
|
||||||
|
let age = payload.age;
|
||||||
|
let checked_in = payload.checked_in.unwrap_or(false);
|
||||||
|
let inside = payload.inside.unwrap_or(false);
|
||||||
|
|
||||||
|
let person = match payload.id {
|
||||||
|
Some(id) => sqlx::query_as::<_, Person>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO persons (id, name, age, phone_number, checked_in, inside)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, name, age, phone_number, checked_in, inside
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(age)
|
||||||
|
.bind(phone_number)
|
||||||
|
.bind(checked_in)
|
||||||
|
.bind(inside)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
|
||||||
|
None => sqlx::query_as::<_, Person>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO persons (name, age, phone_number, checked_in, inside)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, name, age, phone_number, checked_in, inside
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(age)
|
||||||
|
.bind(phone_number)
|
||||||
|
.bind(checked_in)
|
||||||
|
.bind(inside)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(PersonActionResponse {
|
||||||
|
person: person.into(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_checked_in(
|
||||||
|
state: &State<AppState>,
|
||||||
|
id: i32,
|
||||||
|
value: bool,
|
||||||
|
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||||
|
let person = sqlx::query_as::<_, Person>(
|
||||||
|
r#"
|
||||||
|
UPDATE persons
|
||||||
|
SET checked_in = $2,
|
||||||
|
inside = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, name, age, phone_number, checked_in, inside
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(value)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match person {
|
||||||
|
Some(person) => Ok(Json(PersonActionResponse {
|
||||||
|
person: person.into(),
|
||||||
|
})),
|
||||||
|
None => Err(ApiError::not_found("Personen hittades inte.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_inside(
|
||||||
|
state: &State<AppState>,
|
||||||
|
id: i32,
|
||||||
|
value: bool,
|
||||||
|
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||||
|
let person = sqlx::query_as::<_, Person>(
|
||||||
|
r#"
|
||||||
|
UPDATE persons
|
||||||
|
SET inside = $2
|
||||||
|
WHERE id = $1
|
||||||
|
AND checked_in = TRUE
|
||||||
|
RETURNING id, name, age, phone_number, checked_in, inside
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(value)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match person {
|
||||||
|
Some(person) => Ok(Json(PersonActionResponse {
|
||||||
|
person: person.into(),
|
||||||
|
})),
|
||||||
|
None => {
|
||||||
|
let checked =
|
||||||
|
sqlx::query_scalar::<_, bool>("SELECT checked_in FROM persons WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match checked {
|
||||||
|
Some(_) => Err(ApiError::bad_request(
|
||||||
|
"Personen måste vara incheckad för att uppdatera inne/ute.",
|
||||||
|
)),
|
||||||
|
None => Err(ApiError::not_found("Personen hittades inte.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_db_error(err: sqlx::Error, context: &str) -> ApiError {
|
||||||
|
if let sqlx::Error::Database(db_err) = &err {
|
||||||
|
if let Some(code) = db_err.code() {
|
||||||
|
if code == "23505" {
|
||||||
|
return ApiError::bad_request(
|
||||||
|
"Krock i databasen – kontrollera id eller telefonnummer.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("Database error ({}): {:?}", context, db_err);
|
||||||
|
return ApiError::internal("Databasfel.");
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Database error ({}): {:?}", context, err);
|
||||||
|
ApiError::internal("Databasfel.")
|
||||||
|
}
|
||||||
86
api/src/models.rs
Normal file
86
api/src/models.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use rocket::serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: i32,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
pub struct Person {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub age: i32,
|
||||||
|
pub phone_number: String,
|
||||||
|
pub checked_in: bool,
|
||||||
|
pub inside: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct PersonResponse {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub age: i32,
|
||||||
|
pub phone_number: String,
|
||||||
|
pub checked_in: bool,
|
||||||
|
pub inside: bool,
|
||||||
|
pub under_ten: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Person> for PersonResponse {
|
||||||
|
fn from(person: Person) -> Self {
|
||||||
|
let under_ten = person.age < 10;
|
||||||
|
PersonResponse {
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
|
age: person.age,
|
||||||
|
phone_number: person.phone_number,
|
||||||
|
checked_in: person.checked_in,
|
||||||
|
inside: person.inside,
|
||||||
|
under_ten,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct PersonsResponse {
|
||||||
|
pub persons: Vec<PersonResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct PersonActionResponse {
|
||||||
|
pub person: PersonResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct NewPersonRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub age: i32,
|
||||||
|
pub phone_number: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub checked_in: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub inside: Option<bool>,
|
||||||
|
}
|
||||||
110
api/src/seed.rs
Normal file
110
api/src/seed.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use bcrypt::hash;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub async fn run(pool: &PgPool, admin_username: &str, admin_password: &str) -> anyhow::Result<()> {
|
||||||
|
ensure_admin_user(pool, admin_username, admin_password).await?;
|
||||||
|
ensure_person_seed(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_admin_user(pool: &PgPool, username: &str, password: &str) -> anyhow::Result<()> {
|
||||||
|
let exists: bool =
|
||||||
|
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM users WHERE username = $1)")
|
||||||
|
.bind(username)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("Kunde inte kontrollera befintlig admin-användare")?;
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
let password_hash = hash(password, bcrypt::DEFAULT_COST)
|
||||||
|
.context("Misslyckades att hasha admin-lösenord")?;
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO users (username, password_hash) VALUES ($1, $2)")
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.context("Misslyckades att skapa admin-användare")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_person_seed(pool: &PgPool) -> anyhow::Result<()> {
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM persons")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("Kunde inte räkna personer i databasen")?;
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let persons = generate_people();
|
||||||
|
|
||||||
|
for person in persons {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO persons (name, age, phone_number, checked_in, inside) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
)
|
||||||
|
.bind(&person.name)
|
||||||
|
.bind(person.age)
|
||||||
|
.bind(&person.phone_number)
|
||||||
|
.bind(person.checked_in)
|
||||||
|
.bind(person.inside)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.context("Misslyckades att lägga till seed-person")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PersonSeed {
|
||||||
|
name: String,
|
||||||
|
age: i32,
|
||||||
|
phone_number: String,
|
||||||
|
checked_in: bool,
|
||||||
|
inside: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_people() -> Vec<PersonSeed> {
|
||||||
|
let first_names = vec![
|
||||||
|
"Alex", "Bianca", "Cecilia", "David", "Elias", "Fatima", "Gabriel", "Hanna", "Isak",
|
||||||
|
"Johanna", "Karin", "Liam", "Maja", "Nils", "Olivia",
|
||||||
|
];
|
||||||
|
|
||||||
|
let last_names = vec![
|
||||||
|
"Andersson",
|
||||||
|
"Berg",
|
||||||
|
"Carlsson",
|
||||||
|
"Dahl",
|
||||||
|
"Ek",
|
||||||
|
"Fransson",
|
||||||
|
"Gustafsson",
|
||||||
|
"Holm",
|
||||||
|
"Isaksson",
|
||||||
|
"Johansson",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut people = Vec::with_capacity(first_names.len() * last_names.len());
|
||||||
|
|
||||||
|
let mut idx: usize = 0;
|
||||||
|
for first in &first_names {
|
||||||
|
for last in &last_names {
|
||||||
|
let name = format!("{} {}", first, last);
|
||||||
|
let age = 5 + ((idx * 11) % 60) as i32;
|
||||||
|
let phone_number = format!("070{:03}{:04}", idx % 1_000, (idx * 37) % 10_000);
|
||||||
|
people.push(PersonSeed {
|
||||||
|
name,
|
||||||
|
age,
|
||||||
|
phone_number,
|
||||||
|
checked_in: false,
|
||||||
|
inside: false,
|
||||||
|
});
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
people
|
||||||
|
}
|
||||||
92
web/src/routes/create/+page.server.ts
Normal file
92
web/src/routes/create/+page.server.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { fail, type Actions } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, fetch }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const name = formData.get('name')?.toString().trim() ?? '';
|
||||||
|
const ageRaw = formData.get('age')?.toString().trim() ?? '';
|
||||||
|
const phone = formData.get('phone_number')?.toString().trim() ?? '';
|
||||||
|
const manualId = formData.get('manual_id')?.toString().trim() ?? '';
|
||||||
|
const checkedIn = formData.get('checked_in') === 'on';
|
||||||
|
const inside = formData.get('inside') === 'on';
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
name,
|
||||||
|
age: ageRaw,
|
||||||
|
phone_number: phone,
|
||||||
|
manual_id: manualId,
|
||||||
|
checked_in: checkedIn,
|
||||||
|
inside
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return fail(400, {
|
||||||
|
errors: { name: 'Ange ett namn.' },
|
||||||
|
values
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedAge = Number.parseInt(ageRaw, 10);
|
||||||
|
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
||||||
|
return fail(400, {
|
||||||
|
errors: { age: 'Ålder måste vara ett heltal större än eller lika med 0.' },
|
||||||
|
values
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phone) {
|
||||||
|
return fail(400, {
|
||||||
|
errors: { phone_number: 'Ange ett telefonnummer.' },
|
||||||
|
values
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
age: parsedAge,
|
||||||
|
phone_number: phone,
|
||||||
|
checked_in: checkedIn,
|
||||||
|
inside
|
||||||
|
};
|
||||||
|
|
||||||
|
if (manualId.length > 0) {
|
||||||
|
const parsedId = Number.parseInt(manualId, 10);
|
||||||
|
if (Number.isNaN(parsedId) || parsedId < 0) {
|
||||||
|
return fail(400, {
|
||||||
|
errors: { manual_id: 'ID måste vara ett positivt heltal om det anges.' },
|
||||||
|
values
|
||||||
|
});
|
||||||
|
}
|
||||||
|
payload.id = parsedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/persons', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Kunde inte skapa personen.';
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
message = body.message ?? message;
|
||||||
|
} catch {
|
||||||
|
if (text.trim().length > 0) {
|
||||||
|
message = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fail(response.status, {
|
||||||
|
errors: { general: message },
|
||||||
|
values
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: 'Personen har lagts till.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,217 +1,142 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
'use client';
|
|
||||||
'use runes';
|
'use runes';
|
||||||
import { goto } from '$app/navigation';
|
const props = $props<import('./$types').PageData>();
|
||||||
import type { Person } from '$lib/types';
|
|
||||||
|
|
||||||
let name = $state('');
|
type FormValues = {
|
||||||
let age = $state('');
|
name: string;
|
||||||
let phoneNumber = $state('');
|
age: string;
|
||||||
let manualId = $state('');
|
phone_number: string;
|
||||||
let checkedIn = $state(false);
|
manual_id: string;
|
||||||
let inside = $state(false);
|
checked_in: boolean;
|
||||||
let loading = $state(false);
|
inside: boolean;
|
||||||
let errorMessage = $state('');
|
};
|
||||||
let successMessage = $state('');
|
|
||||||
|
|
||||||
async function apiFetch(url: string, init?: RequestInit) {
|
type FormErrors = {
|
||||||
const response = await fetch(url, init);
|
name?: string;
|
||||||
if (response.status === 401) {
|
age?: string;
|
||||||
await goto('/login');
|
phone_number?: string;
|
||||||
return null;
|
manual_id?: string;
|
||||||
}
|
general?: string;
|
||||||
return response;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function resetForm() {
|
const defaults: FormValues = {
|
||||||
name = '';
|
name: '',
|
||||||
age = '';
|
age: '',
|
||||||
phoneNumber = '';
|
phone_number: '',
|
||||||
manualId = '';
|
manual_id: '',
|
||||||
checkedIn = false;
|
checked_in: false,
|
||||||
inside = false;
|
inside: false
|
||||||
}
|
};
|
||||||
|
|
||||||
async function handleSubmit(event: SubmitEvent) {
|
const values = $derived({
|
||||||
event.preventDefault();
|
...defaults,
|
||||||
errorMessage = '';
|
...(props.form?.values ?? {})
|
||||||
successMessage = '';
|
} as FormValues);
|
||||||
|
|
||||||
const trimmedName = name.trim();
|
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
|
||||||
const trimmedPhone = phoneNumber.trim();
|
const success = $derived(props.form?.success ?? null);
|
||||||
const parsedAge = Number.parseInt(age, 10);
|
|
||||||
const trimmedId = manualId.trim();
|
|
||||||
|
|
||||||
if (!trimmedName) {
|
|
||||||
errorMessage = 'Ange ett namn.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
|
||||||
errorMessage = 'Ålder måste vara ett heltal större än eller lika med 0.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!trimmedPhone) {
|
|
||||||
errorMessage = 'Ange ett telefonnummer.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
|
||||||
name: trimmedName,
|
|
||||||
age: parsedAge,
|
|
||||||
phone_number: trimmedPhone,
|
|
||||||
checked_in: checkedIn,
|
|
||||||
inside
|
|
||||||
};
|
|
||||||
|
|
||||||
if (trimmedId) {
|
|
||||||
const parsedId = Number.parseInt(trimmedId, 10);
|
|
||||||
if (Number.isNaN(parsedId) || parsedId < 0) {
|
|
||||||
errorMessage = 'ID måste vara ett positivt heltal om det anges.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that the ID is not already in use
|
|
||||||
try {
|
|
||||||
const idCheckResponse = await apiFetch(
|
|
||||||
`/api/persons/search?q=${encodeURIComponent(trimmedId)}`
|
|
||||||
);
|
|
||||||
if (idCheckResponse) {
|
|
||||||
if (idCheckResponse.ok) {
|
|
||||||
const existingData = await idCheckResponse.json();
|
|
||||||
const collision = existingData.persons?.some(
|
|
||||||
(person: Person) => person.id === parsedId
|
|
||||||
);
|
|
||||||
if (collision) {
|
|
||||||
errorMessage = 'Det angivna ID:t används redan.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const text = await idCheckResponse.text();
|
|
||||||
try {
|
|
||||||
const body = JSON.parse(text);
|
|
||||||
errorMessage = body.message ?? 'Kontroll av ID misslyckades.';
|
|
||||||
} catch {
|
|
||||||
errorMessage = text || 'Kontroll av ID misslyckades.';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('ID uniqueness check failed', err);
|
|
||||||
errorMessage = 'Kunde inte kontrollera ID. Försök igen.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.id = parsedId;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const response = await apiFetch('/api/persons', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!response) return;
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
if (!response.ok) {
|
|
||||||
try {
|
|
||||||
const body = JSON.parse(text);
|
|
||||||
errorMessage = body.message ?? 'Kunde inte skapa personen.';
|
|
||||||
} catch {
|
|
||||||
errorMessage = text || 'Kunde inte skapa personen.';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
successMessage = 'Personen har lagts till.';
|
|
||||||
resetForm();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Create person failed', err);
|
|
||||||
errorMessage = 'Ett oväntat fel inträffade vid skapandet.';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<h2 class="text-lg font-semibold text-slate-800">Lägg till person</h2>
|
<h2 class="text-lg font-semibold text-slate-800">Lägg till person</h2>
|
||||||
<p class="mb-4 text-sm text-slate-500">Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.</p>
|
<p class="mb-4 text-sm text-slate-500">Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.</p>
|
||||||
<form class="grid gap-4 md:grid-cols-2" onsubmit={handleSubmit}>
|
<form method="POST" class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
bind:value={name}
|
name="name"
|
||||||
|
value={values.name}
|
||||||
required
|
required
|
||||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
/>
|
/>
|
||||||
|
{#if errors.name}
|
||||||
|
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="age">Ålder</label>
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="age">Ålder</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="age"
|
id="age"
|
||||||
|
name="age"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={age}
|
value={values.age}
|
||||||
required
|
required
|
||||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
/>
|
/>
|
||||||
|
{#if errors.age}
|
||||||
|
<p class="mt-1 text-sm text-red-600">{errors.age}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone">Telefonnummer</label>
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone">Telefonnummer</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
id="phone"
|
id="phone"
|
||||||
bind:value={phoneNumber}
|
name="phone_number"
|
||||||
|
value={values.phone_number}
|
||||||
required
|
required
|
||||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
/>
|
/>
|
||||||
|
{#if errors.phone_number}
|
||||||
|
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id">ID (valfritt)</label>
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id">ID (valfritt)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="manual-id"
|
id="manual-id"
|
||||||
|
name="manual_id"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={manualId}
|
value={values.manual_id}
|
||||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
/>
|
/>
|
||||||
|
{#if errors.manual_id}
|
||||||
|
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input id="checked-in" type="checkbox" bind:checked={checkedIn} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
<input
|
||||||
|
id="checked-in"
|
||||||
|
type="checkbox"
|
||||||
|
name="checked_in"
|
||||||
|
value="on"
|
||||||
|
checked={values.checked_in}
|
||||||
|
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
<label for="checked-in" class="text-sm text-slate-700">Markera som incheckad</label>
|
<label for="checked-in" class="text-sm text-slate-700">Markera som incheckad</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input id="inside" type="checkbox" bind:checked={inside} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
<input
|
||||||
|
id="inside"
|
||||||
|
type="checkbox"
|
||||||
|
name="inside"
|
||||||
|
value="on"
|
||||||
|
checked={values.inside}
|
||||||
|
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
|
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
|
||||||
</div>
|
</div>
|
||||||
{#if errorMessage}
|
{#if errors.general}
|
||||||
<p class="md:col-span-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
<p class="md:col-span-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errors.general}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if successMessage}
|
{#if success}
|
||||||
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{successMessage}</p>
|
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{success}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="md:col-span-2 flex items-center gap-3">
|
<div class="md:col-span-2 flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
|
||||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
|
||||||
>
|
>
|
||||||
{loading ? 'Sparar…' : 'Spara person'}
|
Spara person
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="reset"
|
||||||
onclick={resetForm}
|
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100"
|
||||||
disabled={loading}
|
|
||||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
>
|
||||||
Rensa fält
|
Rensa fält
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue