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"
|
||||
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "macros", "migrate"] }
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process"] }
|
||||
csv = "1.3"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
FROM rustlang/rust:nightly as builder
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev
|
||||
WORKDIR /app
|
||||
|
||||
# Cache dependencies
|
||||
|
|
@ -10,8 +11,8 @@ RUN cargo build --release
|
|||
COPY . .
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim as runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||
FROM debian:trixie-slim as runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
||||
|
|
|
|||
|
|
@ -21,10 +21,24 @@ pub struct AppConfig {
|
|||
pub cookie_same_site: SameSite,
|
||||
pub admin_username: 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 {
|
||||
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")
|
||||
.map_err(|_| ConfigError::MissingEnv("DATABASE_URL".to_string()))?;
|
||||
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_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 {
|
||||
database_url,
|
||||
database_max_connections,
|
||||
|
|
@ -72,6 +97,13 @@ impl AppConfig {
|
|||
cookie_same_site,
|
||||
admin_username,
|
||||
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_same_site: SameSite,
|
||||
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]
|
||||
|
|
@ -60,6 +67,13 @@ async fn main() -> Result<(), rocket::Error> {
|
|||
cookie_secure: config.cookie_secure,
|
||||
cookie_same_site: config.cookie_same_site,
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use csv::ReaderBuilder;
|
|||
use rocket::data::ToByteUnit;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{postgres::PgRow, QueryBuilder, Row};
|
||||
|
||||
enum SearchCondition {
|
||||
|
|
@ -27,10 +28,279 @@ pub fn routes() -> Vec<Route> {
|
|||
mark_outside,
|
||||
create_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>")]
|
||||
pub async fn search_persons(
|
||||
_user: AuthUser,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ services:
|
|||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
|
@ -26,6 +26,14 @@ services:
|
|||
JWT_COOKIE_SECURE: ${JWT_COOKIE_SECURE:-false}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
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:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -256,11 +256,13 @@
|
|||
{/if}
|
||||
</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">
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
-->
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -340,11 +340,13 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
|||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<!--
|
||||
{#if isLowerGrade(person)}
|
||||
<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.
|
||||
</p>
|
||||
{/if}
|
||||
-->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -314,11 +314,13 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
{#if isLowerGrade(person)}
|
||||
<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.
|
||||
</p>
|
||||
{/if}
|
||||
-->
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@
|
|||
|
||||
let importMessage = $state('');
|
||||
let importSummary = $state('');
|
||||
let importPreview = $state<string[]>([]);
|
||||
let importError = $state('');
|
||||
let importLoading = $state(false);
|
||||
let importResult = $state<
|
||||
|
|
@ -52,7 +51,6 @@
|
|||
errors: { line: number; message: string }[];
|
||||
}
|
||||
>(null);
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
|
||||
const values = $derived({
|
||||
...defaults,
|
||||
|
|
@ -62,73 +60,23 @@
|
|||
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
|
||||
const success = $derived(props.form?.success ?? null);
|
||||
|
||||
function triggerFileImport() {
|
||||
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) {
|
||||
async function importFromExternalDb() {
|
||||
importLoading = true;
|
||||
importMessage = 'Hämtar deltagare från extern databas…';
|
||||
importSummary = '';
|
||||
importError = '';
|
||||
importResult = null;
|
||||
try {
|
||||
const response = await fetch('/api/persons/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'text/csv'
|
||||
},
|
||||
body: raw
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Kunde inte importera CSV-filen.';
|
||||
let message = 'Kunde inte hämta deltagare.';
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
message = body.message ?? message;
|
||||
|
|
@ -162,14 +110,15 @@
|
|||
failed: data.failed ?? 0,
|
||||
errors: data.errors ?? []
|
||||
};
|
||||
importSummary = `Läste in ${lineCount} rader.`;
|
||||
importSummary = 'Import klar.';
|
||||
importError = '';
|
||||
} catch (err) {
|
||||
console.error('Misslyckades att skicka CSV till servern', err);
|
||||
importError = 'Ett fel uppstod när filen skickades till servern.';
|
||||
console.error('Misslyckades att importera från extern källa', err);
|
||||
importError = 'Ett fel uppstod när data hämtades från extern källa.';
|
||||
importResult = null;
|
||||
} finally {
|
||||
importLoading = false;
|
||||
importMessage = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -185,20 +134,13 @@
|
|||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileSelection}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={triggerFileImport}
|
||||
on:click={() => void importFromExternalDb()}
|
||||
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"
|
||||
>
|
||||
{importLoading ? 'Importerar…' : 'Importera från CSV'}
|
||||
{importLoading ? 'Hämtar…' : 'Hämta deltagare'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -208,16 +150,8 @@
|
|||
{#if importSummary}
|
||||
<p class="mb-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{importSummary}</p>
|
||||
{/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}
|
||||
<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 importResult && !importLoading}
|
||||
<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}
|
||||
</div>
|
||||
</header>
|
||||
<!--
|
||||
{#if isLowerGrade(person)}
|
||||
<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.
|
||||
</p>
|
||||
{/if}
|
||||
-->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -3,17 +3,9 @@ import type { RequestHandler } from './$types';
|
|||
import { proxyRequest } from '$lib/server/backend';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const bodyText = await event.request.text();
|
||||
if (!bodyText.trim()) {
|
||||
throw error(400, 'CSV-filen innehåller inga rader.');
|
||||
}
|
||||
|
||||
const { response, setCookies } = await proxyRequest(event, '/persons/import', {
|
||||
const { response, setCookies } = await proxyRequest(event, '/persons/import/external', {
|
||||
method: 'POST',
|
||||
body: bodyText,
|
||||
headers: {
|
||||
'content-type': 'text/csv'
|
||||
}
|
||||
// No body needed; backend fetches participants from external source.
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
|
|
|||
Loading…
Reference in a new issue