adding api

This commit is contained in:
Sebastian 2025-09-20 15:18:41 +02:00
parent 5a7cfd8f9a
commit 88d7738409
15 changed files with 4197 additions and 150 deletions

5
api/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Generated build artifacts
/target
# Rust backup files
**/*.rs.bk

3066
api/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

17
api/Cargo.toml Normal file
View 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"] }

View file

@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View 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()
);

View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@

464
api/src/main.rs Normal file
View 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
View 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
View 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
}

View 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.'
};
}
};

View file

@ -1,217 +1,142 @@
<script lang="ts">
'use client';
'use runes';
import { goto } from '$app/navigation';
import type { Person } from '$lib/types';
const props = $props<import('./$types').PageData>();
let name = $state('');
let age = $state('');
let phoneNumber = $state('');
let manualId = $state('');
let checkedIn = $state(false);
let inside = $state(false);
let loading = $state(false);
let errorMessage = $state('');
let successMessage = $state('');
type FormValues = {
name: string;
age: string;
phone_number: string;
manual_id: string;
checked_in: boolean;
inside: boolean;
};
async function apiFetch(url: string, init?: RequestInit) {
const response = await fetch(url, init);
if (response.status === 401) {
await goto('/login');
return null;
}
return response;
}
type FormErrors = {
name?: string;
age?: string;
phone_number?: string;
manual_id?: string;
general?: string;
};
function resetForm() {
name = '';
age = '';
phoneNumber = '';
manualId = '';
checkedIn = false;
inside = false;
}
const defaults: FormValues = {
name: '',
age: '',
phone_number: '',
manual_id: '',
checked_in: false,
inside: false
};
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
errorMessage = '';
successMessage = '';
const values = $derived({
...defaults,
...(props.form?.values ?? {})
} as FormValues);
const trimmedName = name.trim();
const trimmedPhone = phoneNumber.trim();
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;
}
}
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
const success = $derived(props.form?.success ?? null);
</script>
<div class="space-y-6">
<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>
<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">
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
<input
type="text"
id="name"
bind:value={name}
name="name"
value={values.name}
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"
/>
{#if errors.name}
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
{/if}
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-600" for="age">Ålder</label>
<input
type="number"
id="age"
name="age"
min="0"
bind:value={age}
value={values.age}
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"
/>
{#if errors.age}
<p class="mt-1 text-sm text-red-600">{errors.age}</p>
{/if}
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone">Telefonnummer</label>
<input
type="tel"
id="phone"
bind:value={phoneNumber}
name="phone_number"
value={values.phone_number}
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"
/>
{#if errors.phone_number}
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
{/if}
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id">ID (valfritt)</label>
<input
type="number"
id="manual-id"
name="manual_id"
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"
/>
{#if errors.manual_id}
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
{/if}
</div>
<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>
</div>
<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>
</div>
{#if errorMessage}
<p class="md:col-span-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
{#if errors.general}
<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 successMessage}
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{successMessage}</p>
{#if success}
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{success}</p>
{/if}
<div class="md:col-span-2 flex items-center gap-3">
<button
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 disabled:cursor-not-allowed disabled:opacity-70"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
>
{loading ? 'Sparar…' : 'Spara person'}
Spara person
</button>
<button
type="button"
onclick={resetForm}
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"
type="reset"
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100"
>
Rensa fält
</button>