MOAAARE
This commit is contained in:
parent
8e0acb7bc8
commit
710c7acff6
12 changed files with 356 additions and 97 deletions
|
|
@ -14,5 +14,5 @@ serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "macros", "migrate"] }
|
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "macros", "migrate"] }
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process"] }
|
||||||
csv = "1.3"
|
csv = "1.3"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
FROM rustlang/rust:nightly as builder
|
FROM rustlang/rust:nightly as builder
|
||||||
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Cache dependencies
|
# Cache dependencies
|
||||||
|
|
@ -10,8 +11,8 @@ RUN cargo build --release
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
FROM debian:bookworm-slim as runtime
|
FROM debian:trixie-slim as runtime
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,24 @@ pub struct AppConfig {
|
||||||
pub cookie_same_site: SameSite,
|
pub cookie_same_site: SameSite,
|
||||||
pub admin_username: String,
|
pub admin_username: String,
|
||||||
pub admin_password: String,
|
pub admin_password: String,
|
||||||
|
|
||||||
|
pub external_persons_url: Option<String>,
|
||||||
|
pub external_persons_api_key: Option<String>,
|
||||||
|
pub external_client_cert_file: Option<String>,
|
||||||
|
pub external_client_key_file: Option<String>,
|
||||||
|
pub external_ca_cert_file: Option<String>,
|
||||||
|
pub external_danger_accept_invalid_certs: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
fn opt_nonempty(key: &str) -> Option<String> {
|
||||||
|
env::var(key)
|
||||||
|
.ok()
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
let database_url = env::var("DATABASE_URL")
|
let database_url = env::var("DATABASE_URL")
|
||||||
.map_err(|_| ConfigError::MissingEnv("DATABASE_URL".to_string()))?;
|
.map_err(|_| ConfigError::MissingEnv("DATABASE_URL".to_string()))?;
|
||||||
let jwt_secret = env::var("JWT_SECRET")
|
let jwt_secret = env::var("JWT_SECRET")
|
||||||
|
|
@ -61,6 +75,17 @@ impl AppConfig {
|
||||||
let admin_username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string());
|
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());
|
let admin_password = env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".to_string());
|
||||||
|
|
||||||
|
let external_persons_url = opt_nonempty("EXTERNAL_PERSONS_URL");
|
||||||
|
let external_persons_api_key = opt_nonempty("EXTERNAL_PERSONS_API_KEY");
|
||||||
|
let external_client_cert_file = opt_nonempty("EXTERNAL_CLIENT_CERT_FILE");
|
||||||
|
let external_client_key_file = opt_nonempty("EXTERNAL_CLIENT_KEY_FILE");
|
||||||
|
let external_ca_cert_file = opt_nonempty("EXTERNAL_CA_CERT_FILE");
|
||||||
|
let external_danger_accept_invalid_certs =
|
||||||
|
opt_nonempty("EXTERNAL_DANGER_ACCEPT_INVALID_CERTS")
|
||||||
|
.map(|v| v.to_lowercase())
|
||||||
|
.map(|v| matches!(v.as_str(), "true" | "1" | "yes"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
database_url,
|
database_url,
|
||||||
database_max_connections,
|
database_max_connections,
|
||||||
|
|
@ -72,6 +97,13 @@ impl AppConfig {
|
||||||
cookie_same_site,
|
cookie_same_site,
|
||||||
admin_username,
|
admin_username,
|
||||||
admin_password,
|
admin_password,
|
||||||
|
|
||||||
|
external_persons_url,
|
||||||
|
external_persons_api_key,
|
||||||
|
external_client_cert_file,
|
||||||
|
external_client_key_file,
|
||||||
|
external_ca_cert_file,
|
||||||
|
external_danger_accept_invalid_certs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ pub struct AppState {
|
||||||
pub cookie_secure: bool,
|
pub cookie_secure: bool,
|
||||||
pub cookie_same_site: SameSite,
|
pub cookie_same_site: SameSite,
|
||||||
pub event_sender: broadcast::Sender<AppEvent>,
|
pub event_sender: broadcast::Sender<AppEvent>,
|
||||||
|
|
||||||
|
pub external_persons_url: Option<String>,
|
||||||
|
pub external_persons_api_key: Option<String>,
|
||||||
|
pub external_client_cert_file: Option<String>,
|
||||||
|
pub external_client_key_file: Option<String>,
|
||||||
|
pub external_ca_cert_file: Option<String>,
|
||||||
|
pub external_danger_accept_invalid_certs: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::main]
|
#[rocket::main]
|
||||||
|
|
@ -60,6 +67,13 @@ async fn main() -> Result<(), rocket::Error> {
|
||||||
cookie_secure: config.cookie_secure,
|
cookie_secure: config.cookie_secure,
|
||||||
cookie_same_site: config.cookie_same_site,
|
cookie_same_site: config.cookie_same_site,
|
||||||
event_sender,
|
event_sender,
|
||||||
|
|
||||||
|
external_persons_url: config.external_persons_url,
|
||||||
|
external_persons_api_key: config.external_persons_api_key,
|
||||||
|
external_client_cert_file: config.external_client_cert_file,
|
||||||
|
external_client_key_file: config.external_client_key_file,
|
||||||
|
external_ca_cert_file: config.external_ca_cert_file,
|
||||||
|
external_danger_accept_invalid_certs: config.external_danger_accept_invalid_certs,
|
||||||
};
|
};
|
||||||
|
|
||||||
let rocket = rocket::build()
|
let rocket = rocket::build()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use csv::ReaderBuilder;
|
||||||
use rocket::data::ToByteUnit;
|
use rocket::data::ToByteUnit;
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use rocket::Route;
|
use rocket::Route;
|
||||||
|
use serde::Deserialize;
|
||||||
use sqlx::{postgres::PgRow, QueryBuilder, Row};
|
use sqlx::{postgres::PgRow, QueryBuilder, Row};
|
||||||
|
|
||||||
enum SearchCondition {
|
enum SearchCondition {
|
||||||
|
|
@ -27,10 +28,279 @@ pub fn routes() -> Vec<Route> {
|
||||||
mark_outside,
|
mark_outside,
|
||||||
create_person,
|
create_person,
|
||||||
update_person,
|
update_person,
|
||||||
import_persons
|
import_persons,
|
||||||
|
import_persons_external
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ExternalParticipant {
|
||||||
|
pub id: i32,
|
||||||
|
pub lan_id: i32,
|
||||||
|
pub first_name: String,
|
||||||
|
pub surname: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub grade: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub guardian_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub guardian_phone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_visiting: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum ExternalImportResponse {
|
||||||
|
Wrapper {
|
||||||
|
#[serde(default)]
|
||||||
|
code: Option<i32>,
|
||||||
|
participants: Vec<ExternalParticipant>,
|
||||||
|
},
|
||||||
|
Participants(Vec<ExternalParticipant>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::post("/import/external")]
|
||||||
|
pub async fn import_persons_external(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &rocket::State<AppState>,
|
||||||
|
) -> Result<Json<ImportPersonsResponse>, ApiError> {
|
||||||
|
let url = state
|
||||||
|
.external_persons_url
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| ApiError::bad_request("Saknar konfiguration: EXTERNAL_PERSONS_URL"))?;
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new("curl");
|
||||||
|
cmd.arg("-s")
|
||||||
|
.arg("-v") // Added verbose for better debugging
|
||||||
|
.arg("-L")
|
||||||
|
.arg("--max-time")
|
||||||
|
.arg("30");
|
||||||
|
|
||||||
|
if state.external_danger_accept_invalid_certs {
|
||||||
|
cmd.arg("-k");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ca_path) = state.external_ca_cert_file.as_deref() {
|
||||||
|
cmd.arg("--cacert").arg(ca_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(cert_path), Some(key_path)) = (
|
||||||
|
state.external_client_cert_file.as_deref(),
|
||||||
|
state.external_client_key_file.as_deref(),
|
||||||
|
) {
|
||||||
|
cmd.arg("--cert").arg(cert_path);
|
||||||
|
cmd.arg("--key").arg(key_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(api_key) = state.external_persons_api_key.as_deref() {
|
||||||
|
cmd.arg("-H").arg(format!("x-api-key: {}", api_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.arg(url);
|
||||||
|
|
||||||
|
let output = cmd.output().await.map_err(|err| {
|
||||||
|
eprintln!("Failed to execute curl: {err:?}");
|
||||||
|
ApiError::internal("Kunde inte starta HTTP-klient (curl).")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
eprintln!("curl failed with status {}: {}", output.status, stderr);
|
||||||
|
eprintln!("curl stdout (body if any): {}", stdout);
|
||||||
|
return Err(ApiError::internal(format!(
|
||||||
|
"Extern källa svarade med fel (curl status {}). Body: {}",
|
||||||
|
output.status, stdout
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = output.stdout;
|
||||||
|
|
||||||
|
let participants: Vec<ExternalParticipant> =
|
||||||
|
match serde_json::from_slice::<ExternalImportResponse>(&bytes) {
|
||||||
|
Ok(ExternalImportResponse::Wrapper {
|
||||||
|
code: _,
|
||||||
|
participants,
|
||||||
|
}) => participants,
|
||||||
|
Ok(ExternalImportResponse::Participants(participants)) => participants,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Failed to parse external import JSON: {err:?}");
|
||||||
|
return Err(ApiError::internal(
|
||||||
|
"Kunde inte tolka data från extern källa.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut imported = 0usize;
|
||||||
|
let mut updated = 0usize;
|
||||||
|
let mut errors: Vec<ImportPersonError> = Vec::new();
|
||||||
|
|
||||||
|
for (index, participant) in participants.into_iter().enumerate() {
|
||||||
|
let line_number = index + 1;
|
||||||
|
|
||||||
|
let first_name = participant.first_name.trim().to_string();
|
||||||
|
if first_name.is_empty() {
|
||||||
|
errors.push(ImportPersonError {
|
||||||
|
line: line_number,
|
||||||
|
message: "Förnamn saknas.".to_string(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_name = participant.surname.trim().to_string();
|
||||||
|
if last_name.is_empty() {
|
||||||
|
errors.push(ImportPersonError {
|
||||||
|
line: line_number,
|
||||||
|
message: "Efternamn saknas.".to_string(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let grade = participant
|
||||||
|
.grade
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| v.trim())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(|v| v.parse::<i32>())
|
||||||
|
.transpose();
|
||||||
|
|
||||||
|
let grade = match grade {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
errors.push(ImportPersonError {
|
||||||
|
line: line_number,
|
||||||
|
message: "Ogiltigt klassvärde.".to_string(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(value) = grade {
|
||||||
|
if value < 0 {
|
||||||
|
errors.push(ImportPersonError {
|
||||||
|
line: line_number,
|
||||||
|
message: "Klass måste vara ett tal större än eller lika med 0.".to_string(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent_name = participant
|
||||||
|
.guardian_name
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| v.trim())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(|v| v.to_string());
|
||||||
|
|
||||||
|
let parent_phone_number = normalize_phone_number_lenient(
|
||||||
|
participant
|
||||||
|
.guardian_phone
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| v.trim().to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let visitor = participant.is_visiting.unwrap_or(0) != 0;
|
||||||
|
let sleeping_spot = false;
|
||||||
|
|
||||||
|
let id = if participant.lan_id > 0 {
|
||||||
|
participant.lan_id
|
||||||
|
} else {
|
||||||
|
participant.id
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO persons (
|
||||||
|
id,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
grade,
|
||||||
|
parent_name,
|
||||||
|
parent_phone_number,
|
||||||
|
checked_in,
|
||||||
|
inside,
|
||||||
|
visitor,
|
||||||
|
sleeping_spot
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE, $7, $8)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name,
|
||||||
|
grade = EXCLUDED.grade,
|
||||||
|
parent_name = EXCLUDED.parent_name,
|
||||||
|
parent_phone_number = EXCLUDED.parent_phone_number,
|
||||||
|
checked_in = EXCLUDED.checked_in,
|
||||||
|
inside = EXCLUDED.inside,
|
||||||
|
visitor = EXCLUDED.visitor,
|
||||||
|
sleeping_spot = EXCLUDED.sleeping_spot
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
grade,
|
||||||
|
parent_name,
|
||||||
|
parent_phone_number,
|
||||||
|
checked_in,
|
||||||
|
inside,
|
||||||
|
visitor,
|
||||||
|
sleeping_spot,
|
||||||
|
(xmax = 0) AS inserted
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&first_name)
|
||||||
|
.bind(&last_name)
|
||||||
|
.bind(grade)
|
||||||
|
.bind(parent_name)
|
||||||
|
.bind(parent_phone_number)
|
||||||
|
.bind(visitor)
|
||||||
|
.bind(sleeping_spot)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(row) => {
|
||||||
|
let person = match build_person_from_row(&row) {
|
||||||
|
Ok(person) => person,
|
||||||
|
Err(message) => {
|
||||||
|
errors.push(ImportPersonError {
|
||||||
|
line: line_number,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let inserted = row.try_get::<bool, _>("inserted").unwrap_or(false);
|
||||||
|
if inserted {
|
||||||
|
imported += 1;
|
||||||
|
} else {
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: PersonResponse = person.into();
|
||||||
|
let _ = state
|
||||||
|
.event_sender
|
||||||
|
.send(AppEvent::PersonUpdated { person: response });
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Failed to upsert person during external import: {err:?}");
|
||||||
|
errors.push(ImportPersonError {
|
||||||
|
line: line_number,
|
||||||
|
message: "Kunde inte spara personen i databasen.".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(ImportPersonsResponse {
|
||||||
|
imported,
|
||||||
|
updated,
|
||||||
|
failed: errors.len(),
|
||||||
|
errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::get("/search?<q>")]
|
#[rocket::get("/search?<q>")]
|
||||||
pub async fn search_persons(
|
pub async fn search_persons(
|
||||||
_user: AuthUser,
|
_user: AuthUser,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
@ -26,6 +26,14 @@ services:
|
||||||
JWT_COOKIE_SECURE: ${JWT_COOKIE_SECURE:-false}
|
JWT_COOKIE_SECURE: ${JWT_COOKIE_SECURE:-false}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||||
|
EXTERNAL_PERSONS_URL: ${EXTERNAL_PERSONS_URL:-}
|
||||||
|
EXTERNAL_PERSONS_API_KEY: ${EXTERNAL_PERSONS_API_KEY:-}
|
||||||
|
EXTERNAL_CLIENT_CERT_FILE: ${EXTERNAL_CLIENT_CERT_FILE:-}
|
||||||
|
EXTERNAL_CLIENT_KEY_FILE: ${EXTERNAL_CLIENT_KEY_FILE:-}
|
||||||
|
EXTERNAL_CA_CERT_FILE: ${EXTERNAL_CA_CERT_FILE:-}
|
||||||
|
EXTERNAL_DANGER_ACCEPT_INVALID_CERTS: ${EXTERNAL_DANGER_ACCEPT_INVALID_CERTS:-false}
|
||||||
|
volumes:
|
||||||
|
- ./certs:/app/certs:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -256,11 +256,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if isLowerGrade(person)}
|
<!--
|
||||||
|
{#if isLowerGrade(person)}
|
||||||
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
-->
|
||||||
<div class="mt-4 flex gap-3">
|
<div class="mt-4 flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -340,11 +340,13 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<!--
|
||||||
{#if isLowerGrade(person)}
|
{#if isLowerGrade(person)}
|
||||||
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
-->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -314,11 +314,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!--
|
||||||
{#if isLowerGrade(person)}
|
{#if isLowerGrade(person)}
|
||||||
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
-->
|
||||||
<div class="mt-4 flex gap-3">
|
<div class="mt-4 flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@
|
||||||
|
|
||||||
let importMessage = $state('');
|
let importMessage = $state('');
|
||||||
let importSummary = $state('');
|
let importSummary = $state('');
|
||||||
let importPreview = $state<string[]>([]);
|
|
||||||
let importError = $state('');
|
let importError = $state('');
|
||||||
let importLoading = $state(false);
|
let importLoading = $state(false);
|
||||||
let importResult = $state<
|
let importResult = $state<
|
||||||
|
|
@ -52,7 +51,6 @@
|
||||||
errors: { line: number; message: string }[];
|
errors: { line: number; message: string }[];
|
||||||
}
|
}
|
||||||
>(null);
|
>(null);
|
||||||
let fileInput: HTMLInputElement | null = null;
|
|
||||||
|
|
||||||
const values = $derived({
|
const values = $derived({
|
||||||
...defaults,
|
...defaults,
|
||||||
|
|
@ -62,73 +60,23 @@
|
||||||
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
|
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
|
||||||
const success = $derived(props.form?.success ?? null);
|
const success = $derived(props.form?.success ?? null);
|
||||||
|
|
||||||
function triggerFileImport() {
|
async function importFromExternalDb() {
|
||||||
fileInput?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelection(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
const file = target.files?.[0] ?? null;
|
|
||||||
if (!file) {
|
|
||||||
importMessage = '';
|
|
||||||
importError = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeKb = Math.max(1, Math.round(file.size / 1024));
|
|
||||||
importMessage = `Vald fil: ${file.name} (${sizeKb} KB)`;
|
|
||||||
importError = '';
|
|
||||||
importSummary = '';
|
|
||||||
importPreview = [];
|
|
||||||
importResult = null;
|
|
||||||
readCsvFile(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCsvFile(file: File) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
const text = typeof reader.result === 'string' ? reader.result : '';
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
importSummary = 'Filen verkar sakna innehåll.';
|
|
||||||
importPreview = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = trimmed.split(/\r?\n/);
|
|
||||||
const previewCount = Math.min(5, lines.length);
|
|
||||||
importSummary = `Läste in ${lines.length} rader.`;
|
|
||||||
importPreview = lines.slice(0, previewCount);
|
|
||||||
|
|
||||||
void uploadCsvContent(text, lines.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.onerror = () => {
|
|
||||||
console.error('Kunde inte läsa CSV-filen', reader.error);
|
|
||||||
importError = 'Kunde inte läsa innehållet i filen. Försök igen.';
|
|
||||||
importSummary = '';
|
|
||||||
importPreview = [];
|
|
||||||
importResult = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadCsvContent(raw: string, lineCount: number) {
|
|
||||||
importLoading = true;
|
importLoading = true;
|
||||||
|
importMessage = 'Hämtar deltagare från extern databas…';
|
||||||
|
importSummary = '';
|
||||||
|
importError = '';
|
||||||
|
importResult = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/persons/import', {
|
const response = await fetch('/api/persons/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'text/csv'
|
'content-type': 'application/json'
|
||||||
},
|
}
|
||||||
body: raw
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = 'Kunde inte importera CSV-filen.';
|
let message = 'Kunde inte hämta deltagare.';
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(text);
|
const body = JSON.parse(text);
|
||||||
message = body.message ?? message;
|
message = body.message ?? message;
|
||||||
|
|
@ -162,14 +110,15 @@
|
||||||
failed: data.failed ?? 0,
|
failed: data.failed ?? 0,
|
||||||
errors: data.errors ?? []
|
errors: data.errors ?? []
|
||||||
};
|
};
|
||||||
importSummary = `Läste in ${lineCount} rader.`;
|
importSummary = 'Import klar.';
|
||||||
importError = '';
|
importError = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Misslyckades att skicka CSV till servern', err);
|
console.error('Misslyckades att importera från extern källa', err);
|
||||||
importError = 'Ett fel uppstod när filen skickades till servern.';
|
importError = 'Ett fel uppstod när data hämtades från extern källa.';
|
||||||
importResult = null;
|
importResult = null;
|
||||||
} finally {
|
} finally {
|
||||||
importLoading = false;
|
importLoading = false;
|
||||||
|
importMessage = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -185,20 +134,13 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".csv,text/csv"
|
|
||||||
class="hidden"
|
|
||||||
bind:this={fileInput}
|
|
||||||
onchange={handleFileSelection}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={triggerFileImport}
|
on:click={() => void importFromExternalDb()}
|
||||||
disabled={importLoading}
|
disabled={importLoading}
|
||||||
class="rounded-full border border-indigo-200 px-3 py-2 text-sm font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
|
class="rounded-full border border-indigo-200 px-3 py-2 text-sm font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{importLoading ? 'Importerar…' : 'Importera från CSV'}
|
{importLoading ? 'Hämtar…' : 'Hämta deltagare'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -208,16 +150,8 @@
|
||||||
{#if importSummary}
|
{#if importSummary}
|
||||||
<p class="mb-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{importSummary}</p>
|
<p class="mb-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{importSummary}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if importPreview.length}
|
|
||||||
<div class="mb-4 rounded-md border border-slate-200 bg-slate-50 p-3">
|
|
||||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Förhandsgranskning (första {importPreview.length} rader)
|
|
||||||
</p>
|
|
||||||
<pre class="max-h-48 overflow-auto whitespace-pre-wrap text-xs text-slate-700">{importPreview.join('\n')}</pre>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if importLoading}
|
{#if importLoading}
|
||||||
<p class="mb-2 text-sm text-slate-600">Importerar...</p>
|
<p class="mb-2 text-sm text-slate-600">Hämtar...</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if importResult && !importLoading}
|
{#if importResult && !importLoading}
|
||||||
<div class="mb-4 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
<div class="mb-4 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
|
|
|
||||||
|
|
@ -270,11 +270,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<!--
|
||||||
{#if isLowerGrade(person)}
|
{#if isLowerGrade(person)}
|
||||||
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
-->
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,9 @@ import type { RequestHandler } from './$types';
|
||||||
import { proxyRequest } from '$lib/server/backend';
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const bodyText = await event.request.text();
|
const { response, setCookies } = await proxyRequest(event, '/persons/import/external', {
|
||||||
if (!bodyText.trim()) {
|
|
||||||
throw error(400, 'CSV-filen innehåller inga rader.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { response, setCookies } = await proxyRequest(event, '/persons/import', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: bodyText,
|
// No body needed; backend fetches participants from external source.
|
||||||
headers: {
|
|
||||||
'content-type': 'text/csv'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue