diff --git a/ht_booking/.gitignore b/ht_booking/.gitignore
index 510f9fb..e2305bd 100644
--- a/ht_booking/.gitignore
+++ b/ht_booking/.gitignore
@@ -17,4 +17,7 @@ target/
*.pdb
*.sqlite
-*.sqlite-*
\ No newline at end of file
+*.sqlite-*
+
+# Local secrets (hardcoded admin credentials)
+.env
\ No newline at end of file
diff --git a/ht_booking/Cargo.lock b/ht_booking/Cargo.lock
index 90491da..156e3d3 100644
--- a/ht_booking/Cargo.lock
+++ b/ht_booking/Cargo.lock
@@ -1670,6 +1670,7 @@ dependencies = [
"axum",
"axum-extra",
"chrono",
+ "dotenvy",
"fluent-templates",
"include_dir",
"insta",
diff --git a/ht_booking/Cargo.toml b/ht_booking/Cargo.toml
index 07756f3..3d71053 100644
--- a/ht_booking/Cargo.toml
+++ b/ht_booking/Cargo.toml
@@ -39,7 +39,8 @@ include_dir = { version = "0.7" }
fluent-templates = { version = "0.13", features = ["tera"] }
unic-langid = { version = "0.9" }
# /view engine
-axum-extra = { version = "0.10", features = ["form"] }
+axum-extra = { version = "0.10", features = ["form", "cookie"] }
+dotenvy = { version = "0.15" }
[[bin]]
name = "ht_booking-cli"
diff --git a/ht_booking/assets/i18n/de-DE/main.ftl b/ht_booking/assets/i18n/de-DE/main.ftl
deleted file mode 100644
index ced609f..0000000
--- a/ht_booking/assets/i18n/de-DE/main.ftl
+++ /dev/null
@@ -1,4 +0,0 @@
-hello-world = Hallo Welt!
-greeting = Hallochen { $name }!
- .placeholder = Hallo Freund!
-about = Uber
diff --git a/ht_booking/assets/i18n/en-US/main.ftl b/ht_booking/assets/i18n/en-US/main.ftl
deleted file mode 100644
index 9d4d5e7..0000000
--- a/ht_booking/assets/i18n/en-US/main.ftl
+++ /dev/null
@@ -1,10 +0,0 @@
-hello-world = Hello World!
-greeting = Hello { $name }!
- .placeholder = Hello Friend!
-about = About
-simple = simple text
-reference = simple text with a reference: { -something }
-parameter = text with a { $param }
-parameter2 = text one { $param } second { $multi-word-param }
-email = text with an EMAIL("example@example.org")
-fallback = this should fall back
diff --git a/ht_booking/assets/i18n/en/main.ftl b/ht_booking/assets/i18n/en/main.ftl
new file mode 100644
index 0000000..492bc26
--- /dev/null
+++ b/ht_booking/assets/i18n/en/main.ftl
@@ -0,0 +1,43 @@
+brand = Tennis Court Booking
+nav-calendar = Calendar
+nav-admin = Admin login
+admin-title = Admin
+calendar-title = Court Calendar
+court-label = Court
+prev-week = Previous week
+this-week = This week
+next-week = Next week
+free = Free
+booked = Booked
+no-courts = No courts available yet.
+day-mon = Mon
+day-tue = Tue
+day-wed = Wed
+day-thu = Thu
+day-fri = Fri
+day-sat = Sat
+day-sun = Sun
+login-title = Admin Login
+email = Email
+password = Password
+login-button = Sign in
+login-error = Invalid email or password.
+logout = Log out
+manage-courts = Courts
+back-to-calendar = Back to calendar
+add-booking = New Booking
+edit-booking = Edit Booking
+date = Date
+hour = Hour
+booking-color = Colour
+booking-name = Name
+booking-contact = Contact
+booking-note = Note
+save = Save
+cancel = Cancel
+delete = Delete
+confirm-delete = Delete this booking?
+court-name = Name
+court-surface = Surface
+court-indoor = Indoor
+add-court = Add Court
diff --git a/ht_booking/assets/i18n/sk/main.ftl b/ht_booking/assets/i18n/sk/main.ftl
new file mode 100644
index 0000000..f321cbb
--- /dev/null
+++ b/ht_booking/assets/i18n/sk/main.ftl
@@ -0,0 +1,43 @@
+brand = Rezervácia tenisového kurtu
+nav-calendar = Kalendár
+nav-admin = Prihlásenie admina
+admin-title = Admin
+calendar-title = Kalendár kurtu
+court-label = Kurt
+prev-week = Predchádzajúci týždeň
+this-week = Tento týždeň
+next-week = Nasledujúci týždeň
+free = Voľné
+booked = Obsadené
+no-courts = Zatiaľ nie sú k dispozícii žiadne kurty.
+day-mon = Po
+day-tue = Ut
+day-wed = St
+day-thu = Št
+day-fri = Pi
+day-sat = So
+day-sun = Ne
+login-title = Prihlásenie admina
+email = E-mail
+password = Heslo
+login-button = Prihlásiť sa
+login-error = Nesprávny e-mail alebo heslo.
+logout = Odhlásiť sa
+manage-courts = Kurty
+back-to-calendar = Späť na kalendár
+add-booking = Nová rezervácia
+edit-booking = Upraviť rezerváciu
+date = Dátum
+hour = Hodina
+booking-color = Farba
+booking-name = Meno
+booking-contact = Kontakt
+booking-note = Poznámka
+save = Uložiť
+cancel = Zrušiť
+delete = Zmazať
+confirm-delete = Zmazať túto rezerváciu?
+court-name = Názov
+court-surface = Povrch
+court-indoor = Krytý
+add-court = Pridať kurt
diff --git a/ht_booking/assets/views/admin/booking_form.html b/ht_booking/assets/views/admin/booking_form.html
new file mode 100644
index 0000000..eff56be
--- /dev/null
+++ b/ht_booking/assets/views/admin/booking_form.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+
+{% block title %}
+{% if mode == "edit" %}{{ t(key="edit-booking", lang=lang) }}{% else %}{{ t(key="add-booking", lang=lang) }}{% endif %}
+{% endblock title %}
+
+{% block content %}
+
-
-
-
-
- {% block page_title %}{% endblock page_title %}
-
- {% block content %}
- {% endblock content %}
-
-
-
+
+
- {% endblock js %}
+
+ {% block content %}{% endblock content %}
+
+ {% block js %}{% endblock js %}
-
\ No newline at end of file
+
diff --git a/ht_booking/assets/views/calendar/week.html b/ht_booking/assets/views/calendar/week.html
new file mode 100644
index 0000000..a02c026
--- /dev/null
+++ b/ht_booking/assets/views/calendar/week.html
@@ -0,0 +1,82 @@
+{% extends "base.html" %}
+
+{% block title %}{{ t(key="calendar-title", lang=lang) }}{% endblock title %}
+
+{% block content %}
+
+
+ {% if is_admin %}{{ t(key="admin-title", lang=lang) }}{% else %}{{ t(key="calendar-title", lang=lang) }}{% endif %}
+
+ {% if has_courts %}
+
+ {% endif %}
+
+
+{% if has_courts %}
+
+
+
{{ court_name }} · {{ week_label }}
+
+
+
+
+
+
+
+ {% for d in days %}
+
+ {{ t(key=d.key, lang=lang) }}
+ {{ d.num }}
+
+ {% endfor %}
+
+
+
+ {% for row in rows %}
+
+ {{ row.hour_label }}
+ {% for cell in row.cells %}
+
+ {% if cell.booked %}
+ {% if is_admin %}
+ {{ cell.name }}
+ {% else %}
+ {{ t(key="booked", lang=lang) }}
+ {% endif %}
+ {% else %}
+ {% if is_admin %}
+ +
+ {% else %}
+ {{ t(key="free", lang=lang) }}
+ {% endif %}
+ {% endif %}
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+{% else %}
+
{{ t(key="no-courts", lang=lang) }}
+{% endif %}
+{% endblock content %}
diff --git a/ht_booking/assets/views/court/create.html b/ht_booking/assets/views/court/create.html
deleted file mode 100644
index c24d590..0000000
--- a/ht_booking/assets/views/court/create.html
+++ /dev/null
@@ -1,37 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}
-Create court
-{% endblock title %}
-
-{% block page_title %}
-Create new court
-{% endblock page_title %}
-
-{% block content %}
-
-{% endblock content %}
-
-{% block js %}
-
-{% endblock js %}
\ No newline at end of file
diff --git a/ht_booking/assets/views/court/edit.html b/ht_booking/assets/views/court/edit.html
deleted file mode 100644
index 5894cce..0000000
--- a/ht_booking/assets/views/court/edit.html
+++ /dev/null
@@ -1,42 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}
-Edit court: {{ item.id }}
-{% endblock title %}
-
-{% block page_title %}
-Edit court: {{ item.id }}
-{% endblock page_title %}
-
-{% block content %}
-
-{% endblock content %}
-
-{% block js %}
-
-{% endblock js %}
\ No newline at end of file
diff --git a/ht_booking/assets/views/court/list.html b/ht_booking/assets/views/court/list.html
deleted file mode 100644
index 02c5640..0000000
--- a/ht_booking/assets/views/court/list.html
+++ /dev/null
@@ -1,86 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}
-List of court
-{% endblock title %}
-
-{% block page_title %}
-court
-{% endblock page_title %}
-
-{% block content %}
-
-
- {% if items %}
-
-
-
-
-
-
-
- {{"name" | capitalize }}
-
-
- {{"surface" | capitalize }}
-
-
- {{"indoor" | capitalize }}
-
-
- Actions
-
-
-
-
- {% for item in items %}
-
-
- {{item.name | escape }}
-
-
- {{item.surface | escape }}
-
-
- {{ item.indoor }}
-
-
- Edit
-
-
- {% endfor %}
-
-
-
-
-
-
-
- {% else %}
-
-
-
-
Nothing Here Yet
- There are no records to display. Add a new record to get started!
-
- Create
-
-
-
-
- {% endif %}
-
-
-
-{% endblock content %}
\ No newline at end of file
diff --git a/ht_booking/assets/views/court/show.html b/ht_booking/assets/views/court/show.html
deleted file mode 100644
index 62fb0b9..0000000
--- a/ht_booking/assets/views/court/show.html
+++ /dev/null
@@ -1,22 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}
-View court: {{ item.id }}
-{% endblock title %}
-
-{% block content %}
-
View court: {{ item.id }}
-
-
- name: {{item.name}}
-
-
- surface: {{item.surface}}
-
-
- indoor: {{item.indoor}}
-
-
-
Back to courts
-
-{% endblock content %}
\ No newline at end of file
diff --git a/ht_booking/assets/views/home/hello.html b/ht_booking/assets/views/home/hello.html
deleted file mode 100644
index 6b97c39..0000000
--- a/ht_booking/assets/views/home/hello.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
- find this tera template at
assets/views/home/hello.html:
-
-
- {{ t(key="hello-world", lang="en-US") }},
-
- {{ t(key="hello-world", lang="de-DE") }}
-
-
-
\ No newline at end of file
diff --git a/ht_booking/assets/views/home/home.html b/ht_booking/assets/views/home/home.html
deleted file mode 100644
index d0f9f16..0000000
--- a/ht_booking/assets/views/home/home.html
+++ /dev/null
@@ -1,181 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}ht_booking · Loco SaaS starter{% endblock title %}
-
-{% block page_title %}What this Loco app gives you out of the box{% endblock page_title %}
-
-{% block content %}
-
- This page replaces the default Loco fallback screen. Every model, controller,
- migration and route below was produced by cargo loco generate —
- nothing here is hand-written boilerplate.
-
-
-
-
- 1 · Authentication — JWT API
-
- The SaaS starter ships a complete JWT auth flow in src/controllers/auth.rs.
- It is a JSON API (no HTML login pages) — so here is a live console that calls those exact endpoints.
-
-
-
-
-
-
- Method
- Path
- JSON body
- Purpose
-
-
-
- POST /api/auth/register name, email, password create an account
- POST /api/auth/login email, password returns a JWT token
- GET /api/auth/current — (Bearer token) info about the logged-in user
- POST /api/auth/forgot email start password reset
- POST /api/auth/reset token, password set a new password
- GET /api/auth/verify/{token} — verify an email address
- POST /api/auth/magic-link email email a passwordless login link
- GET /api/auth/magic-link/{token} — exchange the link for a JWT
- POST /api/auth/resend-verification-mail email resend the verification mail
-
-
-
-
-
-
-
-
-
-
-
Current user
-
Sends the JWT from a successful login as a Bearer token.
-
GET /current
-
-
-
-
-
- Active JWT:
-
-
-
-
-
- 2 · Database CRUD — Courts
-
- A model, SeaORM entity, migration, controller and Tera views, all generated with one command:
-
- cargo loco generate scaffold court name:string surface:string indoor:bool --html
- Open /courts →
-
-
-
-
- 3 · Health & ops endpoints
- Built-in, no code needed:
-
- /_ping — liveness
- /_health — DB & queue health
- /_readiness — readiness probe
-
-
-
-
-
- 4 · Server-side views & i18n
-
- Tera templates plus Fluent translations. The same key hello-world,
- resolved by the t() function in this template:
-
-
- en-US: {{ t(key="hello-world", lang="en-US") }}
- de-DE: {{ t(key="hello-world", lang="de-DE") }}
-
-
-
-
-
- 5 · Also wired up (CLI-generated when you need them)
-
- Background workers — cargo loco generate worker <name>
- Scheduled / one-off tasks — cargo loco generate task <name>
- Mailers — cargo loco generate mailer <name> (the auth flow already uses one)
- Inspect every route — cargo loco routes
-
-
-{% endblock content %}
-
-{% block js %}
-{% raw %}
-
-{% endraw %}
-{% endblock js %}
diff --git a/ht_booking/migration/src/lib.rs b/ht_booking/migration/src/lib.rs
index 010da00..a2c3d64 100644
--- a/ht_booking/migration/src/lib.rs
+++ b/ht_booking/migration/src/lib.rs
@@ -4,6 +4,7 @@ pub use sea_orm_migration::prelude::*;
mod m20220101_000001_users;
mod m20260515_162423_courts;
+mod m20260515_170417_bookings;
pub struct Migrator;
#[async_trait::async_trait]
@@ -12,6 +13,7 @@ impl MigratorTrait for Migrator {
vec![
Box::new(m20220101_000001_users::Migration),
Box::new(m20260515_162423_courts::Migration),
+ Box::new(m20260515_170417_bookings::Migration),
// inject-above (do not remove this comment)
]
}
diff --git a/ht_booking/migration/src/m20260515_170417_bookings.rs b/ht_booking/migration/src/m20260515_170417_bookings.rs
new file mode 100644
index 0000000..e40f610
--- /dev/null
+++ b/ht_booking/migration/src/m20260515_170417_bookings.rs
@@ -0,0 +1,31 @@
+use loco_rs::schema::*;
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ create_table(m, "bookings",
+ &[
+
+ ("id", ColType::PkAuto),
+
+ ("date", ColType::Date),
+ ("hour", ColType::Integer),
+ ("color", ColType::String),
+ ("name", ColType::String),
+ ("contact", ColType::StringNull),
+ ("note", ColType::TextNull),
+ ],
+ &[
+ ("court", ""),
+ ]
+ ).await
+ }
+
+ async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ drop_table(m, "bookings").await
+ }
+}
diff --git a/ht_booking/src/app.rs b/ht_booking/src/app.rs
index 741e6ad..028b6eb 100644
--- a/ht_booking/src/app.rs
+++ b/ht_booking/src/app.rs
@@ -44,16 +44,16 @@ impl Hooks for App {
}
async fn initializers(_ctx: &AppContext) -> Result
>> {
- Ok(vec![Box::new(
- initializers::view_engine::ViewEngineInitializer,
- )])
+ Ok(vec![
+ Box::new(initializers::view_engine::ViewEngineInitializer),
+ Box::new(initializers::admin_seeder::AdminSeeder),
+ ])
}
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below
- .add_route(controllers::home::routes())
- .add_route(controllers::court::routes())
- .add_route(controllers::auth::routes())
+ .add_route(controllers::calendar::routes())
+ .add_route(controllers::admin::routes())
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?;
diff --git a/ht_booking/src/bin/main.rs b/ht_booking/src/bin/main.rs
index 6bb43e3..3f61c02 100644
--- a/ht_booking/src/bin/main.rs
+++ b/ht_booking/src/bin/main.rs
@@ -4,5 +4,7 @@ use migration::Migrator;
#[tokio::main]
async fn main() -> loco_rs::Result<()> {
+ // Load ADMIN_* credentials (and any DATABASE_URL override) from `.env`.
+ dotenvy::dotenv().ok();
cli::main::().await
}
diff --git a/ht_booking/src/controllers/admin.rs b/ht_booking/src/controllers/admin.rs
new file mode 100644
index 0000000..b37a0b5
--- /dev/null
+++ b/ht_booking/src/controllers/admin.rs
@@ -0,0 +1,392 @@
+#![allow(clippy::missing_errors_doc)]
+#![allow(clippy::unused_async)]
+//! Admin area: cookie-based JWT login and the booking editor.
+//!
+//! There is exactly one admin. Login is gated to the `ADMIN_EMAIL` env value
+//! so any other user row in the DB cannot reach the admin pages.
+
+use axum::extract::FromRequestParts;
+use axum::http::request::Parts;
+use axum::response::Redirect;
+use axum_extra::extract::cookie::{Cookie, CookieJar};
+use loco_rs::auth::jwt;
+use loco_rs::prelude::*;
+use sea_orm::QueryOrder;
+use serde::Deserialize;
+
+use crate::controllers::calendar::{self, build_calendar, current_lang, FIRST_HOUR, LAST_HOUR};
+use crate::models::_entities::{bookings, courts};
+use crate::models::users;
+
+const AUTH_COOKIE: &str = "auth_token";
+
+fn admin_email() -> String {
+ std::env::var("ADMIN_EMAIL").unwrap_or_default()
+}
+
+fn jwt_settings(ctx: &AppContext) -> Option<(String, u64)> {
+ let jwt = ctx.config.auth.as_ref()?.jwt.as_ref()?;
+ Some((jwt.secret.clone(), jwt.expiration))
+}
+
+/// Request guard for admin-only routes. On any failure it redirects to the
+/// login page instead of returning an error.
+pub struct AdminAuth {
+ #[allow(dead_code)]
+ pub user: users::Model,
+}
+
+impl FromRequestParts for AdminAuth {
+ type Rejection = Redirect;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ ctx: &AppContext,
+ ) -> std::result::Result {
+ let deny = || Redirect::to("/admin/login");
+ let admin = admin_email();
+ if admin.is_empty() {
+ return Err(deny());
+ }
+
+ let jar = CookieJar::from_headers(&parts.headers);
+ let token = jar
+ .get(AUTH_COOKIE)
+ .map(|c| c.value().to_string())
+ .ok_or_else(deny)?;
+ let (secret, _) = jwt_settings(ctx).ok_or_else(deny)?;
+ let claims = jwt::JWT::new(&secret)
+ .validate(&token)
+ .map_err(|_| deny())?;
+ let user = users::Model::find_by_pid(&ctx.db, &claims.claims.pid)
+ .await
+ .map_err(|_| deny())?;
+ if user.email != admin {
+ return Err(deny());
+ }
+ Ok(Self { user })
+ }
+}
+
+// ---------------------------------------------------------------- login ----
+
+fn render_login(v: &TeraView, lang: &str, error: bool) -> Result {
+ format::render().view(
+ v,
+ "admin/login.html",
+ data!({ "lang": lang, "is_admin": false, "error": error }),
+ )
+}
+
+#[debug_handler]
+pub async fn login_form(ViewEngine(v): ViewEngine, jar: CookieJar) -> Result {
+ render_login(&v, ¤t_lang(&jar), false)
+}
+
+#[derive(Debug, Deserialize)]
+pub struct LoginForm {
+ pub email: String,
+ pub password: String,
+}
+
+#[debug_handler]
+pub async fn login_submit(
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ jar: CookieJar,
+ Form(form): Form,
+) -> Result {
+ let lang = current_lang(&jar);
+ let admin = admin_email();
+
+ if admin.is_empty() || form.email != admin {
+ return render_login(&v, &lang, true);
+ }
+ let Ok(user) = users::Model::find_by_email(&ctx.db, &form.email).await else {
+ return render_login(&v, &lang, true);
+ };
+ if !user.verify_password(&form.password) {
+ return render_login(&v, &lang, true);
+ }
+
+ let (secret, expiration) =
+ jwt_settings(&ctx).ok_or_else(|| Error::string("JWT is not configured"))?;
+ let token = user.generate_jwt(&secret, expiration)?;
+ let jar = jar.add(
+ Cookie::build((AUTH_COOKIE, token))
+ .path("/")
+ .http_only(true)
+ .build(),
+ );
+ Ok((jar, Redirect::to("/admin")).into_response())
+}
+
+#[debug_handler]
+pub async fn logout(jar: CookieJar) -> Result {
+ let jar = jar.remove(Cookie::build((AUTH_COOKIE, "")).path("/").build());
+ Ok((jar, Redirect::to("/admin/login")).into_response())
+}
+
+// ------------------------------------------------------------ dashboard ----
+
+#[debug_handler]
+pub async fn dashboard(
+ _auth: AdminAuth,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ jar: CookieJar,
+ Query(q): Query,
+) -> Result {
+ let lang = current_lang(&jar);
+ let page = build_calendar(&ctx, &lang, true, q.court, q.week).await?;
+ format::render().view(&v, "calendar/week.html", &page)
+}
+
+// ---------------------------------------------------------------- courts ---
+
+#[debug_handler]
+pub async fn courts_page(
+ _auth: AdminAuth,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ jar: CookieJar,
+) -> Result {
+ let lang = current_lang(&jar);
+ let list = courts::Entity::find()
+ .order_by_asc(courts::Column::Id)
+ .all(&ctx.db)
+ .await?;
+ let items: Vec<_> = list
+ .iter()
+ .map(|c| {
+ data!({
+ "id": c.id,
+ "name": c.name.clone().unwrap_or_else(|| format!("Court {}", c.id)),
+ "surface": c.surface.clone().unwrap_or_default(),
+ "indoor": c.indoor.unwrap_or(false),
+ })
+ })
+ .collect();
+ format::render().view(
+ &v,
+ "admin/courts.html",
+ data!({ "lang": lang, "is_admin": true, "courts": items }),
+ )
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CourtForm {
+ pub name: String,
+ pub surface: Option,
+ pub indoor: Option,
+}
+
+#[debug_handler]
+pub async fn create_court(
+ _auth: AdminAuth,
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ courts::ActiveModel {
+ name: Set(Some(form.name)),
+ surface: Set(form.surface.filter(|s| !s.is_empty())),
+ indoor: Set(Some(form.indoor.is_some())),
+ ..Default::default()
+ }
+ .insert(&ctx.db)
+ .await?;
+ Ok(Redirect::to("/admin/courts").into_response())
+}
+
+// --------------------------------------------------------------- bookings --
+
+fn hour_options() -> Vec {
+ (FIRST_HOUR..=LAST_HOUR)
+ .map(|h| data!({ "v": h, "label": format!("{h:02}:00") }))
+ .collect()
+}
+
+#[derive(Debug, Deserialize)]
+pub struct NewBookingQuery {
+ pub court: Option,
+ pub date: Option,
+ pub hour: Option,
+}
+
+#[debug_handler]
+pub async fn booking_new(
+ _auth: AdminAuth,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ jar: CookieJar,
+ Query(q): Query,
+) -> Result {
+ let lang = current_lang(&jar);
+ let court_list = courts::Entity::find()
+ .order_by_asc(courts::Column::Id)
+ .all(&ctx.db)
+ .await?;
+ let court_id = q
+ .court
+ .or_else(|| court_list.first().map(|c| c.id))
+ .unwrap_or(0);
+ let court_name = court_list
+ .iter()
+ .find(|c| c.id == court_id)
+ .and_then(|c| c.name.clone())
+ .unwrap_or_else(|| format!("Court {court_id}"));
+
+ format::render().view(
+ &v,
+ "admin/booking_form.html",
+ data!({
+ "lang": lang,
+ "is_admin": true,
+ "mode": "new",
+ "action": "/admin/booking",
+ "court_id": court_id,
+ "court_name": court_name,
+ "date": q.date.unwrap_or_default(),
+ "hour": q.hour.unwrap_or(FIRST_HOUR),
+ "color": "#3b82f6",
+ "name": "",
+ "contact": "",
+ "note": "",
+ "hours": hour_options(),
+ "booking_id": 0,
+ }),
+ )
+}
+
+#[derive(Debug, Deserialize)]
+pub struct BookingForm {
+ pub court_id: i32,
+ pub date: String,
+ pub hour: i32,
+ pub color: String,
+ pub name: String,
+ pub contact: Option,
+ pub note: Option,
+}
+
+fn parse_date(s: &str) -> Result {
+ chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
+ .map_err(|_| Error::string("invalid date"))
+}
+
+#[debug_handler]
+pub async fn booking_create(
+ _auth: AdminAuth,
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ let date = parse_date(&form.date)?;
+ bookings::ActiveModel {
+ court_id: Set(form.court_id),
+ date: Set(date),
+ hour: Set(form.hour),
+ color: Set(form.color),
+ name: Set(form.name),
+ contact: Set(form.contact.filter(|s| !s.is_empty())),
+ note: Set(form.note.filter(|s| !s.is_empty())),
+ ..Default::default()
+ }
+ .insert(&ctx.db)
+ .await?;
+ Ok(Redirect::to(&format!("/admin?court={}&week={}", form.court_id, form.date)).into_response())
+}
+
+#[debug_handler]
+pub async fn booking_edit(
+ _auth: AdminAuth,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ jar: CookieJar,
+ Path(id): Path,
+) -> Result {
+ let lang = current_lang(&jar);
+ let booking = bookings::Entity::find_by_id(id)
+ .one(&ctx.db)
+ .await?
+ .ok_or(Error::NotFound)?;
+ let court_name = courts::Entity::find_by_id(booking.court_id)
+ .one(&ctx.db)
+ .await?
+ .and_then(|c| c.name)
+ .unwrap_or_else(|| format!("Court {}", booking.court_id));
+
+ format::render().view(
+ &v,
+ "admin/booking_form.html",
+ data!({
+ "lang": lang,
+ "is_admin": true,
+ "mode": "edit",
+ "action": format!("/admin/booking/{id}"),
+ "court_id": booking.court_id,
+ "court_name": court_name,
+ "date": booking.date.format("%Y-%m-%d").to_string(),
+ "hour": booking.hour,
+ "color": booking.color,
+ "name": booking.name,
+ "contact": booking.contact.unwrap_or_default(),
+ "note": booking.note.unwrap_or_default(),
+ "hours": hour_options(),
+ "booking_id": id,
+ }),
+ )
+}
+
+#[debug_handler]
+pub async fn booking_update(
+ _auth: AdminAuth,
+ State(ctx): State,
+ Path(id): Path,
+ Form(form): Form,
+) -> Result {
+ let date = parse_date(&form.date)?;
+ let booking = bookings::Entity::find_by_id(id)
+ .one(&ctx.db)
+ .await?
+ .ok_or(Error::NotFound)?;
+ let mut active = booking.into_active_model();
+ active.court_id = Set(form.court_id);
+ active.date = Set(date);
+ active.hour = Set(form.hour);
+ active.color = Set(form.color);
+ active.name = Set(form.name);
+ active.contact = Set(form.contact.filter(|s| !s.is_empty()));
+ active.note = Set(form.note.filter(|s| !s.is_empty()));
+ active.update(&ctx.db).await?;
+ Ok(Redirect::to(&format!("/admin?court={}&week={}", form.court_id, form.date)).into_response())
+}
+
+#[debug_handler]
+pub async fn booking_delete(
+ _auth: AdminAuth,
+ State(ctx): State,
+ Path(id): Path,
+) -> Result {
+ let court = bookings::Entity::find_by_id(id)
+ .one(&ctx.db)
+ .await?
+ .map_or(0, |b| b.court_id);
+ bookings::Entity::delete_by_id(id).exec(&ctx.db).await?;
+ Ok(Redirect::to(&format!("/admin?court={court}")).into_response())
+}
+
+pub fn routes() -> Routes {
+ Routes::new()
+ .prefix("admin")
+ .add("/login", get(login_form))
+ .add("/login", post(login_submit))
+ .add("/logout", post(logout))
+ .add("/", get(dashboard))
+ .add("/courts", get(courts_page))
+ .add("/courts", post(create_court))
+ .add("/booking", get(booking_new))
+ .add("/booking", post(booking_create))
+ .add("/booking/{id}", get(booking_edit))
+ .add("/booking/{id}", post(booking_update))
+ .add("/booking/{id}/delete", post(booking_delete))
+}
diff --git a/ht_booking/src/controllers/calendar.rs b/ht_booking/src/controllers/calendar.rs
new file mode 100644
index 0000000..459cd74
--- /dev/null
+++ b/ht_booking/src/controllers/calendar.rs
@@ -0,0 +1,225 @@
+#![allow(clippy::missing_errors_doc)]
+#![allow(clippy::unused_async)]
+//! Public, read-only week-view calendar.
+//!
+//! Shows a 7-day grid (one column per day, one row per hour) for a single
+//! court. Booked slots are coloured; free slots are blank. The same grid is
+//! reused by the admin dashboard with `is_admin = true`.
+
+use axum_extra::extract::cookie::CookieJar;
+use chrono::{Datelike, Duration, NaiveDate, Utc};
+use loco_rs::prelude::*;
+use sea_orm::QueryOrder;
+use serde::{Deserialize, Serialize};
+
+use crate::models::_entities::{bookings, courts};
+
+/// First bookable hour (06:00).
+pub const FIRST_HOUR: i32 = 6;
+/// Last bookable hour slot start (21:00 — i.e. the 21:00-22:00 slot).
+pub const LAST_HOUR: i32 = 21;
+
+const DAY_KEYS: [&str; 7] = [
+ "day-mon", "day-tue", "day-wed", "day-thu", "day-fri", "day-sat", "day-sun",
+];
+
+#[derive(Debug, Deserialize)]
+pub struct CalQuery {
+ pub court: Option,
+ pub week: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct CourtOpt {
+ pub id: i32,
+ pub name: String,
+ pub selected: bool,
+}
+
+#[derive(Debug, Serialize)]
+pub struct DayHead {
+ pub date: String,
+ pub key: String,
+ pub num: u32,
+}
+
+#[derive(Debug, Serialize)]
+pub struct Cell {
+ pub date: String,
+ pub hour: i32,
+ pub booked: bool,
+ pub color: String,
+ pub booking_id: i32,
+ pub name: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct Row {
+ pub hour_label: String,
+ pub cells: Vec,
+}
+
+#[derive(Debug, Serialize)]
+pub struct CalendarPage {
+ pub lang: String,
+ pub is_admin: bool,
+ pub base_path: String,
+ pub has_courts: bool,
+ pub courts: Vec,
+ pub court_id: i32,
+ pub court_name: String,
+ pub week: String,
+ pub week_label: String,
+ pub prev_week: String,
+ pub this_week: String,
+ pub next_week: String,
+ pub days: Vec,
+ pub rows: Vec,
+}
+
+/// Resolves the UI language from the `lang` cookie (`sk` or `en`, default `en`).
+#[must_use]
+pub fn current_lang(jar: &CookieJar) -> String {
+ match jar.get("lang").map(|c| c.value().to_string()) {
+ Some(ref l) if l == "sk" => "sk".to_string(),
+ _ => "en".to_string(),
+ }
+}
+
+fn monday_of(date: NaiveDate) -> NaiveDate {
+ date - Duration::days(i64::from(date.weekday().num_days_from_monday()))
+}
+
+fn week_monday(week: Option<&str>) -> NaiveDate {
+ let base = week
+ .and_then(|w| NaiveDate::parse_from_str(w, "%Y-%m-%d").ok())
+ .unwrap_or_else(|| Utc::now().date_naive());
+ monday_of(base)
+}
+
+/// Builds the calendar grid for the selected court and week.
+pub async fn build_calendar(
+ ctx: &AppContext,
+ lang: &str,
+ is_admin: bool,
+ q_court: Option,
+ q_week: Option,
+) -> Result {
+ let court_list = courts::Entity::find()
+ .order_by_asc(courts::Column::Id)
+ .all(&ctx.db)
+ .await?;
+
+ let selected = q_court
+ .filter(|id| court_list.iter().any(|c| c.id == *id))
+ .or_else(|| court_list.first().map(|c| c.id))
+ .unwrap_or(0);
+
+ let courts_opts: Vec = court_list
+ .iter()
+ .map(|c| CourtOpt {
+ id: c.id,
+ name: c.name.clone().unwrap_or_else(|| format!("Court {}", c.id)),
+ selected: c.id == selected,
+ })
+ .collect();
+
+ let court_name = courts_opts
+ .iter()
+ .find(|c| c.selected)
+ .map(|c| c.name.clone())
+ .unwrap_or_default();
+
+ let monday = week_monday(q_week.as_deref());
+ let sunday = monday + Duration::days(6);
+
+ let days: Vec = (0..7i64)
+ .map(|i| {
+ let d = monday + Duration::days(i);
+ DayHead {
+ date: d.format("%Y-%m-%d").to_string(),
+ key: DAY_KEYS[i as usize].to_string(),
+ num: d.day(),
+ }
+ })
+ .collect();
+
+ let week_bookings = if selected == 0 {
+ Vec::new()
+ } else {
+ bookings::Entity::find()
+ .filter(bookings::Column::CourtId.eq(selected))
+ .filter(bookings::Column::Date.gte(monday))
+ .filter(bookings::Column::Date.lte(sunday))
+ .all(&ctx.db)
+ .await?
+ };
+
+ let rows: Vec = (FIRST_HOUR..=LAST_HOUR)
+ .map(|hour| {
+ let cells = (0..7i64)
+ .map(|i| {
+ let d = monday + Duration::days(i);
+ let iso = d.format("%Y-%m-%d").to_string();
+ match week_bookings.iter().find(|b| b.date == d && b.hour == hour) {
+ Some(b) => Cell {
+ date: iso,
+ hour,
+ booked: true,
+ color: b.color.clone(),
+ booking_id: b.id,
+ name: b.name.clone(),
+ },
+ None => Cell {
+ date: iso,
+ hour,
+ booked: false,
+ color: String::new(),
+ booking_id: 0,
+ name: String::new(),
+ },
+ }
+ })
+ .collect();
+ Row {
+ hour_label: format!("{hour:02}:00"),
+ cells,
+ }
+ })
+ .collect();
+
+ Ok(CalendarPage {
+ lang: lang.to_string(),
+ is_admin,
+ base_path: if is_admin { "/admin" } else { "/" }.to_string(),
+ has_courts: !courts_opts.is_empty(),
+ courts: courts_opts,
+ court_id: selected,
+ court_name,
+ week: monday.format("%Y-%m-%d").to_string(),
+ week_label: format!("{} – {}", monday.format("%d %b"), sunday.format("%d %b %Y")),
+ prev_week: (monday - Duration::days(7)).format("%Y-%m-%d").to_string(),
+ this_week: monday_of(Utc::now().date_naive())
+ .format("%Y-%m-%d")
+ .to_string(),
+ next_week: (monday + Duration::days(7)).format("%Y-%m-%d").to_string(),
+ days,
+ rows,
+ })
+}
+
+#[debug_handler]
+pub async fn index(
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ jar: CookieJar,
+ Query(q): Query,
+) -> Result {
+ let lang = current_lang(&jar);
+ let page = build_calendar(&ctx, &lang, false, q.court, q.week).await?;
+ format::render().view(&v, "calendar/week.html", &page)
+}
+
+pub fn routes() -> Routes {
+ Routes::new().add("/", get(index))
+}
diff --git a/ht_booking/src/controllers/court.rs b/ht_booking/src/controllers/court.rs
deleted file mode 100644
index bb4c3d7..0000000
--- a/ht_booking/src/controllers/court.rs
+++ /dev/null
@@ -1,117 +0,0 @@
-#![allow(clippy::missing_errors_doc)]
-#![allow(clippy::unnecessary_struct_initialization)]
-#![allow(clippy::unused_async)]
-use loco_rs::prelude::*;
-use serde::{Deserialize, Serialize};
-use axum::response::Redirect;
-use axum_extra::extract::Form;
-use sea_orm::{sea_query::Order, QueryOrder};
-
-use crate::{
- models::_entities::courts::{ActiveModel, Column, Entity, Model},
- views,
-};
-
-#[derive(Clone, Debug, Serialize, Deserialize)]
-pub struct Params {
- pub name: Option,
- pub surface: Option,
- pub indoor: Option,
- }
-
-impl Params {
- fn update(&self, item: &mut ActiveModel) {
- item.name = Set(self.name.clone());
- item.surface = Set(self.surface.clone());
- item.indoor = Set(self.indoor);
- }
-}
-
-async fn load_item(ctx: &AppContext, id: i32) -> Result {
- let item = Entity::find_by_id(id).one(&ctx.db).await?;
- item.ok_or_else(|| Error::NotFound)
-}
-
-#[debug_handler]
-pub async fn list(
- ViewEngine(v): ViewEngine,
- State(ctx): State,
-) -> Result {
- let item = Entity::find()
- .order_by(Column::Id, Order::Desc)
- .all(&ctx.db)
- .await?;
- views::court::list(&v, &item)
-}
-
-#[debug_handler]
-pub async fn new(
- ViewEngine(v): ViewEngine,
- State(_ctx): State,
-) -> Result {
- views::court::create(&v)
-}
-
-#[debug_handler]
-pub async fn update(
- Path(id): Path,
- State(ctx): State,
- Form(params): Form,
-) -> Result {
- let item = load_item(&ctx, id).await?;
- let mut item = item.into_active_model();
- params.update(&mut item);
- item.update(&ctx.db).await?;
- Ok(Redirect::to("../courts"))
-}
-
-#[debug_handler]
-pub async fn edit(
- Path(id): Path,
- ViewEngine(v): ViewEngine,
- State(ctx): State,
-) -> Result {
- let item = load_item(&ctx, id).await?;
- views::court::edit(&v, &item)
-}
-
-#[debug_handler]
-pub async fn show(
- Path(id): Path,
- ViewEngine(v): ViewEngine,
- State(ctx): State,
-) -> Result {
- let item = load_item(&ctx, id).await?;
- views::court::show(&v, &item)
-}
-
-#[debug_handler]
-pub async fn add(
- State(ctx): State,
- Form(params): Form,
-) -> Result {
- let mut item = ActiveModel {
- ..Default::default()
- };
- params.update(&mut item);
- item.insert(&ctx.db).await?;
- Ok(Redirect::to("courts"))
-}
-
-#[debug_handler]
-pub async fn remove(Path(id): Path, State(ctx): State) -> Result {
- load_item(&ctx, id).await?.delete(&ctx.db).await?;
- format::empty()
-}
-
-pub fn routes() -> Routes {
- Routes::new()
- .prefix("courts/")
- .add("/", get(list))
- .add("/", post(add))
- .add("new", get(new))
- .add("{id}", get(show))
- .add("{id}/edit", get(edit))
- .add("{id}", delete(remove))
- .add("{id}", post(update))
-}
diff --git a/ht_booking/src/controllers/home.rs b/ht_booking/src/controllers/home.rs
deleted file mode 100644
index b3b53cc..0000000
--- a/ht_booking/src/controllers/home.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-#![allow(clippy::missing_errors_doc)]
-#![allow(clippy::unnecessary_struct_initialization)]
-#![allow(clippy::unused_async)]
-use loco_rs::prelude::*;
-
-#[debug_handler]
-pub async fn home(
- ViewEngine(v): ViewEngine,
- State(_ctx): State
-) -> Result {
- format::render().view(&v, "home/home.html", data!({}))
-}
-
-pub fn routes() -> Routes {
- Routes::new().add("/", get(home))
-}
diff --git a/ht_booking/src/controllers/mod.rs b/ht_booking/src/controllers/mod.rs
index d2d5814..1b8fa67 100644
--- a/ht_booking/src/controllers/mod.rs
+++ b/ht_booking/src/controllers/mod.rs
@@ -1,4 +1,3 @@
+pub mod admin;
pub mod auth;
-
-pub mod court;
-pub mod home;
\ No newline at end of file
+pub mod calendar;
diff --git a/ht_booking/src/initializers/admin_seeder.rs b/ht_booking/src/initializers/admin_seeder.rs
new file mode 100644
index 0000000..04fdca6
--- /dev/null
+++ b/ht_booking/src/initializers/admin_seeder.rs
@@ -0,0 +1,55 @@
+//! Seeds the single hardcoded admin user (and a default court) on boot.
+//!
+//! Credentials are read from the environment (`.env`):
+//! `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `ADMIN_NAME`.
+
+use async_trait::async_trait;
+use loco_rs::prelude::*;
+
+use crate::models::_entities::courts;
+use crate::models::users::{self, RegisterParams};
+
+pub struct AdminSeeder;
+
+#[async_trait]
+impl Initializer for AdminSeeder {
+ fn name(&self) -> String {
+ "admin-seeder".to_string()
+ }
+
+ async fn before_run(&self, ctx: &AppContext) -> Result<()> {
+ let email = std::env::var("ADMIN_EMAIL").unwrap_or_default();
+ let password = std::env::var("ADMIN_PASSWORD").unwrap_or_default();
+ let name = std::env::var("ADMIN_NAME").unwrap_or_else(|_| "Admin".to_string());
+
+ if email.is_empty() || password.is_empty() {
+ tracing::warn!("ADMIN_EMAIL / ADMIN_PASSWORD not set in .env — admin not seeded");
+ } else if users::Model::find_by_email(&ctx.db, &email).await.is_err() {
+ users::Model::create_with_password(
+ &ctx.db,
+ &RegisterParams {
+ email: email.clone(),
+ password,
+ name,
+ },
+ )
+ .await?;
+ tracing::info!(admin = %email, "admin user seeded");
+ }
+
+ // The calendar is per-court, so make sure at least one court exists.
+ if courts::Entity::find().one(&ctx.db).await?.is_none() {
+ courts::ActiveModel {
+ name: Set(Some("Court 1".to_string())),
+ surface: Set(Some("Clay".to_string())),
+ indoor: Set(Some(false)),
+ ..Default::default()
+ }
+ .insert(&ctx.db)
+ .await?;
+ tracing::info!("default court seeded");
+ }
+
+ Ok(())
+ }
+}
diff --git a/ht_booking/src/initializers/mod.rs b/ht_booking/src/initializers/mod.rs
index cbe3470..a5780a7 100644
--- a/ht_booking/src/initializers/mod.rs
+++ b/ht_booking/src/initializers/mod.rs
@@ -1 +1,2 @@
+pub mod admin_seeder;
pub mod view_engine;
diff --git a/ht_booking/src/initializers/view_engine.rs b/ht_booking/src/initializers/view_engine.rs
index 2201078..0546fcd 100644
--- a/ht_booking/src/initializers/view_engine.rs
+++ b/ht_booking/src/initializers/view_engine.rs
@@ -24,7 +24,7 @@ impl Initializer for ViewEngineInitializer {
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result {
let tera_engine = if std::path::Path::new(I18N_DIR).exists() {
let arc = std::sync::Arc::new(
- ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US"))
+ ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en"))
.shared_resources(Some(&[I18N_SHARED.into()]))
.customize(|bundle| bundle.set_use_isolating(false))
.build()
diff --git a/ht_booking/src/models/_entities/bookings.rs b/ht_booking/src/models/_entities/bookings.rs
new file mode 100644
index 0000000..366935b
--- /dev/null
+++ b/ht_booking/src/models/_entities/bookings.rs
@@ -0,0 +1,39 @@
+//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
+
+use sea_orm::entity::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
+#[sea_orm(table_name = "bookings")]
+pub struct Model {
+ pub created_at: DateTimeWithTimeZone,
+ pub updated_at: DateTimeWithTimeZone,
+ #[sea_orm(primary_key)]
+ pub id: i32,
+ pub date: Date,
+ pub hour: i32,
+ pub color: String,
+ pub name: String,
+ pub contact: Option,
+ #[sea_orm(column_type = "Text", nullable)]
+ pub note: Option,
+ pub court_id: i32,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::courts::Entity",
+ from = "Column::CourtId",
+ to = "super::courts::Column::Id",
+ on_update = "Cascade",
+ on_delete = "Cascade"
+ )]
+ Courts,
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Courts.def()
+ }
+}
diff --git a/ht_booking/src/models/_entities/courts.rs b/ht_booking/src/models/_entities/courts.rs
index eb2d77a..efcd0a4 100644
--- a/ht_booking/src/models/_entities/courts.rs
+++ b/ht_booking/src/models/_entities/courts.rs
@@ -16,4 +16,13 @@ pub struct Model {
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
+pub enum Relation {
+ #[sea_orm(has_many = "super::bookings::Entity")]
+ Bookings,
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Bookings.def()
+ }
+}
diff --git a/ht_booking/src/models/_entities/mod.rs b/ht_booking/src/models/_entities/mod.rs
index bd97f98..4f93ff3 100644
--- a/ht_booking/src/models/_entities/mod.rs
+++ b/ht_booking/src/models/_entities/mod.rs
@@ -2,5 +2,6 @@
pub mod prelude;
+pub mod bookings;
pub mod courts;
pub mod users;
diff --git a/ht_booking/src/models/_entities/prelude.rs b/ht_booking/src/models/_entities/prelude.rs
index adab66a..6382987 100644
--- a/ht_booking/src/models/_entities/prelude.rs
+++ b/ht_booking/src/models/_entities/prelude.rs
@@ -1,4 +1,5 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
+pub use super::bookings::Entity as Bookings;
pub use super::courts::Entity as Courts;
pub use super::users::Entity as Users;
diff --git a/ht_booking/src/models/bookings.rs b/ht_booking/src/models/bookings.rs
new file mode 100644
index 0000000..a418c0d
--- /dev/null
+++ b/ht_booking/src/models/bookings.rs
@@ -0,0 +1,28 @@
+use sea_orm::entity::prelude::*;
+pub use super::_entities::bookings::{ActiveModel, Model, Entity};
+pub type Bookings = Entity;
+
+#[async_trait::async_trait]
+impl ActiveModelBehavior for ActiveModel {
+ async fn before_save(self, _db: &C, insert: bool) -> std::result::Result
+ where
+ C: ConnectionTrait,
+ {
+ if !insert && self.updated_at.is_unchanged() {
+ let mut this = self;
+ this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
+ Ok(this)
+ } else {
+ Ok(self)
+ }
+ }
+}
+
+// implement your read-oriented logic here
+impl Model {}
+
+// implement your write-oriented logic here
+impl ActiveModel {}
+
+// implement your custom finders, selectors oriented logic here
+impl Entity {}
diff --git a/ht_booking/src/models/mod.rs b/ht_booking/src/models/mod.rs
index 6fc9ff4..a457492 100644
--- a/ht_booking/src/models/mod.rs
+++ b/ht_booking/src/models/mod.rs
@@ -1,3 +1,4 @@
pub mod _entities;
pub mod users;
pub mod courts;
+pub mod bookings;
diff --git a/ht_booking/src/views/court.rs b/ht_booking/src/views/court.rs
deleted file mode 100644
index 858003c..0000000
--- a/ht_booking/src/views/court.rs
+++ /dev/null
@@ -1,39 +0,0 @@
-use loco_rs::prelude::*;
-
-use crate::models::_entities::courts;
-
-/// Render a list view of `courts`.
-///
-/// # Errors
-///
-/// When there is an issue with rendering the view.
-pub fn list(v: &impl ViewRenderer, items: &Vec) -> Result {
- format::render().view(v, "court/list.html", data!({"items": items}))
-}
-
-/// Render a single `court` view.
-///
-/// # Errors
-///
-/// When there is an issue with rendering the view.
-pub fn show(v: &impl ViewRenderer, item: &courts::Model) -> Result {
- format::render().view(v, "court/show.html", data!({"item": item}))
-}
-
-/// Render a `court` create form.
-///
-/// # Errors
-///
-/// When there is an issue with rendering the view.
-pub fn create(v: &impl ViewRenderer) -> Result {
- format::render().view(v, "court/create.html", data!({}))
-}
-
-/// Render a `court` edit form.
-///
-/// # Errors
-///
-/// When there is an issue with rendering the view.
-pub fn edit(v: &impl ViewRenderer, item: &courts::Model) -> Result {
- format::render().view(v, "court/edit.html", data!({"item": item}))
-}
diff --git a/ht_booking/src/views/mod.rs b/ht_booking/src/views/mod.rs
index 5b21bf7..0e4a05d 100644
--- a/ht_booking/src/views/mod.rs
+++ b/ht_booking/src/views/mod.rs
@@ -1,3 +1 @@
pub mod auth;
-
-pub mod court;
\ No newline at end of file
diff --git a/ht_booking/tests/models/bookings.rs b/ht_booking/tests/models/bookings.rs
new file mode 100644
index 0000000..5c63cdf
--- /dev/null
+++ b/ht_booking/tests/models/bookings.rs
@@ -0,0 +1,31 @@
+use ht_booking::app::App;
+use loco_rs::testing::prelude::*;
+use serial_test::serial;
+
+macro_rules! configure_insta {
+ ($($expr:expr),*) => {
+ let mut settings = insta::Settings::clone_current();
+ settings.set_prepend_module_to_snapshot(false);
+ let _guard = settings.bind_to_scope();
+ };
+}
+
+#[tokio::test]
+#[serial]
+async fn test_model() {
+ configure_insta!();
+
+ let boot = boot_test::().await.unwrap();
+ seed::(&boot.app_context).await.unwrap();
+
+ // query your model, e.g.:
+ //
+ // let item = models::posts::Model::find_by_pid(
+ // &boot.app_context.db,
+ // "11111111-1111-1111-1111-111111111111",
+ // )
+ // .await;
+
+ // snapshot the result:
+ // assert_debug_snapshot!(item);
+}
diff --git a/ht_booking/tests/models/mod.rs b/ht_booking/tests/models/mod.rs
index 39bfff1..d37cd0a 100644
--- a/ht_booking/tests/models/mod.rs
+++ b/ht_booking/tests/models/mod.rs
@@ -1,3 +1,4 @@
mod users;
-mod courts;
\ No newline at end of file
+mod courts;
+mod bookings;
\ No newline at end of file
|