neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Members-only areas with roles, invitations, friends, contributed-by
aecd85b · Gabriel Morell · 2026-10-28 20:47
Message
{commit_body(@commit)}
Files changed
modified
README.md
+28
−0
| 67 | 67 | anyone with a valid invite token self-serve, or keep it off and hand out links |
| 68 | 68 | from the in-app **Invites** page. |
| 69 | 69 | |
| 70 | +## Areas, members and friends | |
| 71 | + | |
| 72 | +Areas are **members-only**: to anyone else an area, its restaurants, lunches | |
| 73 | +and votes don't exist (404). Each member has a role: | |
| 74 | + | |
| 75 | +| Role | Can | | |
| 76 | +|------|-----| | |
| 77 | +| voter | see the area, rank, start/join/vote on lunches, log visits | | |
| 78 | +| editor | also add, edit, archive and refresh restaurants | | |
| 79 | +| admin | also rename the area and manage members and invitations | | |
| 80 | + | |
| 81 | +Whoever creates an area is its admin, and an area always keeps at least one. | |
| 82 | +Admins invite people from the area's **Members** page: an existing user gets a | |
| 83 | +pending invitation on their home page to accept or decline; someone without an | |
| 84 | +account gets a sign-up link from **Invites**, which can carry any areas the | |
| 85 | +inviter administers (with a role each), joined on registration. The checks | |
| 86 | +live in `src/access.rs`; every handler goes through them. | |
| 87 | + | |
| 88 | +**Friends** are mutual (request, then accept). The "who's going" picker lists | |
| 89 | +you, then members who are your friends or have eaten with you in that area, | |
| 90 | +most shared lunches first; everyone else in the area is folded away. Admins | |
| 91 | +are offered their friends as quick invites. Restaurants show who contributed | |
| 92 | +them. | |
| 93 | + | |
| 94 | +The same OSM place can be in several areas (it's unique per area), and area | |
| 95 | +names only need to be unique within your own list, since other people's | |
| 96 | +areas are private. | |
| 97 | + | |
| 70 | 98 | ## Deciding together: outings |
| 71 | 99 | |
| 72 | 100 | "Start a vote" on an area's planner opens an **outing**: a ballot of a few |
modified
assets/f/js/map.js
+5
−3
| 7 | 7 | // data-markers='[{name, lat, lon, rank?, url?}]' |
| 8 | 8 | // data-pick="nearby|point"> optional, see below |
| 9 | 9 | // |
| 10 | −// data-pick="nearby": a click asks /places/nearby what's there and swaps the | |
| 11 | −// answer into #candidates (and also drops a pin, for places OSM lacks). | |
| 10 | +// data-pick="nearby": a click asks data-nearby-url (default /places/nearby) | |
| 11 | +// what's there and swaps the answer into #candidates (and also drops a pin, for places OSM lacks). | |
| 12 | 12 | // data-pick="point": a click only drops the pin. |
| 13 | 13 | // Either way the pin's position lands in #place-form's lat/lon inputs, and |
| 14 | 14 | // clicking a [data-candidate] button fills #place-form from its JSON. |
| 98 | 98 | const osmRef = placeForm()?.elements.osm_ref; |
| 99 | 99 | if (osmRef) osmRef.value = ""; |
| 100 | 100 | if (pick === "nearby") { |
| 101 | − htmx.ajax("GET", `/places/nearby?lat=${e.latlng.lat}&lon=${e.latlng.lng}`, { | |
| 101 | + const base = el.dataset.nearbyUrl || "/places/nearby"; | |
| 102 | + const sep = base.includes("?") ? "&" : "?"; | |
| 103 | + htmx.ajax("GET", `${base}${sep}lat=${e.latlng.lat}&lon=${e.latlng.lng}`, { | |
| 102 | 104 | target: "#candidates", select: "#candidates", swap: "outerHTML", |
| 103 | 105 | }); |
| 104 | 106 | } |
modified
migration/src/lib.rs
+2
−0
| 5 | 5 | mod m20261028_000002_create_outings; |
| 6 | 6 | mod m20261028_000003_obsessed; |
| 7 | 7 | mod m20261028_000004_opening_hours; |
| 8 | +mod m20261028_000005_collaboration; | |
| 8 | 9 | |
| 9 | 10 | pub struct Migrator; |
| 10 | 11 |
| 17 | 18 | Box::new(m20261028_000002_create_outings::Migration), |
| 18 | 19 | Box::new(m20261028_000003_obsessed::Migration), |
| 19 | 20 | Box::new(m20261028_000004_opening_hours::Migration), |
| 21 | + Box::new(m20261028_000005_collaboration::Migration), | |
| 20 | 22 | ] |
| 21 | 23 | } |
| 22 | 24 | } |
added
migration/src/m20261028_000005_collaboration.rs
+298
−0
| 1 | +//! Collaboration: areas get members with roles, people can be invited into | |
| 2 | +//! areas (existing users accept a pending invitation; sign-up invites carry | |
| 3 | +//! area grants applied on registration), and users can friend each other. | |
| 4 | +//! | |
| 5 | +//! Areas become members-only, so an OSM place is now unique per area rather | |
| 6 | +//! than globally — two private areas can both list the same Dunkin' — and | |
| 7 | +//! area names stop being globally unique: someone else's private "Boston" | |
| 8 | +//! mustn't block (or reveal itself to) yours. | |
| 9 | +//! | |
| 10 | +//! Existing data: each area's creator becomes its admin and every existing | |
| 11 | +//! user an editor of every existing area (admin, where the area has no | |
| 12 | +//! recorded creator), so nobody loses access to anything on upgrade. | |
| 13 | +use sea_orm_migration::prelude::*; | |
| 14 | + | |
| 15 | +#[derive(DeriveMigrationName)] | |
| 16 | +pub struct Migration; | |
| 17 | + | |
| 18 | +fn created_at<T: IntoIden>(col: T) -> ColumnDef { | |
| 19 | + ColumnDef::new(col).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()).to_owned() | |
| 20 | +} | |
| 21 | + | |
| 22 | +#[async_trait::async_trait] | |
| 23 | +impl MigrationTrait for Migration { | |
| 24 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 25 | + manager | |
| 26 | + .create_table( | |
| 27 | + Table::create() | |
| 28 | + .table(AreaMembers::Table) | |
| 29 | + .if_not_exists() | |
| 30 | + .col(ColumnDef::new(AreaMembers::AreaId).uuid().not_null()) | |
| 31 | + .col(ColumnDef::new(AreaMembers::UserId).uuid().not_null()) | |
| 32 | + // voter | editor | admin | |
| 33 | + .col(ColumnDef::new(AreaMembers::Role).string_len(16).not_null()) | |
| 34 | + .col(created_at(AreaMembers::CreatedAt)) | |
| 35 | + .primary_key(Index::create().col(AreaMembers::AreaId).col(AreaMembers::UserId)) | |
| 36 | + .foreign_key( | |
| 37 | + ForeignKey::create() | |
| 38 | + .name("fk_area_members_area_id") | |
| 39 | + .from(AreaMembers::Table, AreaMembers::AreaId) | |
| 40 | + .to(Areas::Table, Areas::Id) | |
| 41 | + .on_delete(ForeignKeyAction::Cascade), | |
| 42 | + ) | |
| 43 | + .foreign_key( | |
| 44 | + ForeignKey::create() | |
| 45 | + .name("fk_area_members_user_id") | |
| 46 | + .from(AreaMembers::Table, AreaMembers::UserId) | |
| 47 | + .to(Users::Table, Users::Id) | |
| 48 | + .on_delete(ForeignKeyAction::Cascade), | |
| 49 | + ) | |
| 50 | + .to_owned(), | |
| 51 | + ) | |
| 52 | + .await?; | |
| 53 | + manager | |
| 54 | + .create_index( | |
| 55 | + Index::create() | |
| 56 | + .name("idx_area_members_user_id") | |
| 57 | + .table(AreaMembers::Table) | |
| 58 | + .col(AreaMembers::UserId) | |
| 59 | + .to_owned(), | |
| 60 | + ) | |
| 61 | + .await?; | |
| 62 | + | |
| 63 | + // An existing user invited into an area, until they accept or decline. | |
| 64 | + manager | |
| 65 | + .create_table( | |
| 66 | + Table::create() | |
| 67 | + .table(AreaInvitations::Table) | |
| 68 | + .if_not_exists() | |
| 69 | + .col(ColumnDef::new(AreaInvitations::AreaId).uuid().not_null()) | |
| 70 | + .col(ColumnDef::new(AreaInvitations::UserId).uuid().not_null()) | |
| 71 | + .col(ColumnDef::new(AreaInvitations::Role).string_len(16).not_null()) | |
| 72 | + .col(ColumnDef::new(AreaInvitations::InvitedBy).uuid().null()) | |
| 73 | + .col(created_at(AreaInvitations::CreatedAt)) | |
| 74 | + .primary_key(Index::create().col(AreaInvitations::AreaId).col(AreaInvitations::UserId)) | |
| 75 | + .foreign_key( | |
| 76 | + ForeignKey::create() | |
| 77 | + .name("fk_area_invitations_area_id") | |
| 78 | + .from(AreaInvitations::Table, AreaInvitations::AreaId) | |
| 79 | + .to(Areas::Table, Areas::Id) | |
| 80 | + .on_delete(ForeignKeyAction::Cascade), | |
| 81 | + ) | |
| 82 | + .foreign_key( | |
| 83 | + ForeignKey::create() | |
| 84 | + .name("fk_area_invitations_user_id") | |
| 85 | + .from(AreaInvitations::Table, AreaInvitations::UserId) | |
| 86 | + .to(Users::Table, Users::Id) | |
| 87 | + .on_delete(ForeignKeyAction::Cascade), | |
| 88 | + ) | |
| 89 | + .foreign_key( | |
| 90 | + ForeignKey::create() | |
| 91 | + .name("fk_area_invitations_invited_by") | |
| 92 | + .from(AreaInvitations::Table, AreaInvitations::InvitedBy) | |
| 93 | + .to(Users::Table, Users::Id) | |
| 94 | + .on_delete(ForeignKeyAction::SetNull), | |
| 95 | + ) | |
| 96 | + .to_owned(), | |
| 97 | + ) | |
| 98 | + .await?; | |
| 99 | + | |
| 100 | + // Areas a sign-up invite grants, applied when it's used. | |
| 101 | + manager | |
| 102 | + .create_table( | |
| 103 | + Table::create() | |
| 104 | + .table(RegistrationTokenAreas::Table) | |
| 105 | + .if_not_exists() | |
| 106 | + .col(ColumnDef::new(RegistrationTokenAreas::TokenId).uuid().not_null()) | |
| 107 | + .col(ColumnDef::new(RegistrationTokenAreas::AreaId).uuid().not_null()) | |
| 108 | + .col(ColumnDef::new(RegistrationTokenAreas::Role).string_len(16).not_null()) | |
| 109 | + .primary_key( | |
| 110 | + Index::create() | |
| 111 | + .col(RegistrationTokenAreas::TokenId) | |
| 112 | + .col(RegistrationTokenAreas::AreaId), | |
| 113 | + ) | |
| 114 | + .foreign_key( | |
| 115 | + ForeignKey::create() | |
| 116 | + .name("fk_registration_token_areas_token_id") | |
| 117 | + .from(RegistrationTokenAreas::Table, RegistrationTokenAreas::TokenId) | |
| 118 | + .to(RegistrationTokens::Table, RegistrationTokens::Id) | |
| 119 | + .on_delete(ForeignKeyAction::Cascade), | |
| 120 | + ) | |
| 121 | + .foreign_key( | |
| 122 | + ForeignKey::create() | |
| 123 | + .name("fk_registration_token_areas_area_id") | |
| 124 | + .from(RegistrationTokenAreas::Table, RegistrationTokenAreas::AreaId) | |
| 125 | + .to(Areas::Table, Areas::Id) | |
| 126 | + .on_delete(ForeignKeyAction::Cascade), | |
| 127 | + ) | |
| 128 | + .to_owned(), | |
| 129 | + ) | |
| 130 | + .await?; | |
| 131 | + | |
| 132 | + // One row per pair, in the direction it was asked. Friends are the | |
| 133 | + // accepted rows with you on either side. | |
| 134 | + manager | |
| 135 | + .create_table( | |
| 136 | + Table::create() | |
| 137 | + .table(Friendships::Table) | |
| 138 | + .if_not_exists() | |
| 139 | + .col(ColumnDef::new(Friendships::RequesterId).uuid().not_null()) | |
| 140 | + .col(ColumnDef::new(Friendships::AddresseeId).uuid().not_null()) | |
| 141 | + // pending | accepted | |
| 142 | + .col(ColumnDef::new(Friendships::Status).string_len(16).not_null()) | |
| 143 | + .col(created_at(Friendships::CreatedAt)) | |
| 144 | + .col(ColumnDef::new(Friendships::AcceptedAt).timestamp_with_time_zone().null()) | |
| 145 | + .primary_key(Index::create().col(Friendships::RequesterId).col(Friendships::AddresseeId)) | |
| 146 | + .foreign_key( | |
| 147 | + ForeignKey::create() | |
| 148 | + .name("fk_friendships_requester_id") | |
| 149 | + .from(Friendships::Table, Friendships::RequesterId) | |
| 150 | + .to(Users::Table, Users::Id) | |
| 151 | + .on_delete(ForeignKeyAction::Cascade), | |
| 152 | + ) | |
| 153 | + .foreign_key( | |
| 154 | + ForeignKey::create() | |
| 155 | + .name("fk_friendships_addressee_id") | |
| 156 | + .from(Friendships::Table, Friendships::AddresseeId) | |
| 157 | + .to(Users::Table, Users::Id) | |
| 158 | + .on_delete(ForeignKeyAction::Cascade), | |
| 159 | + ) | |
| 160 | + .to_owned(), | |
| 161 | + ) | |
| 162 | + .await?; | |
| 163 | + manager | |
| 164 | + .create_index( | |
| 165 | + Index::create() | |
| 166 | + .name("idx_friendships_addressee_id") | |
| 167 | + .table(Friendships::Table) | |
| 168 | + .col(Friendships::AddresseeId) | |
| 169 | + .to_owned(), | |
| 170 | + ) | |
| 171 | + .await?; | |
| 172 | + | |
| 173 | + // OSM places: unique per area, not globally. | |
| 174 | + manager | |
| 175 | + .drop_index(Index::drop().name("idx_restaurants_osm_ref").table(Restaurants::Table).to_owned()) | |
| 176 | + .await?; | |
| 177 | + manager | |
| 178 | + .create_index( | |
| 179 | + Index::create() | |
| 180 | + .name("idx_restaurants_area_osm_ref") | |
| 181 | + .table(Restaurants::Table) | |
| 182 | + .col(Restaurants::AreaId) | |
| 183 | + .col(Restaurants::OsmRef) | |
| 184 | + .unique() | |
| 185 | + .to_owned(), | |
| 186 | + ) | |
| 187 | + .await?; | |
| 188 | + | |
| 189 | + let db = manager.get_connection(); | |
| 190 | + db.execute_unprepared("ALTER TABLE areas DROP CONSTRAINT IF EXISTS areas_name_key").await?; | |
| 191 | + db.execute_unprepared( | |
| 192 | + "INSERT INTO area_members (area_id, user_id, role) | |
| 193 | + SELECT id, created_by, 'admin' FROM areas WHERE created_by IS NOT NULL | |
| 194 | + ON CONFLICT DO NOTHING", | |
| 195 | + ) | |
| 196 | + .await?; | |
| 197 | + db.execute_unprepared( | |
| 198 | + "INSERT INTO area_members (area_id, user_id, role) | |
| 199 | + SELECT a.id, u.id, CASE WHEN a.created_by IS NULL THEN 'admin' ELSE 'editor' END | |
| 200 | + FROM areas a CROSS JOIN users u | |
| 201 | + ON CONFLICT DO NOTHING", | |
| 202 | + ) | |
| 203 | + .await?; | |
| 204 | + Ok(()) | |
| 205 | + } | |
| 206 | + | |
| 207 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 208 | + manager | |
| 209 | + .get_connection() | |
| 210 | + .execute_unprepared("ALTER TABLE areas ADD CONSTRAINT areas_name_key UNIQUE (name)") | |
| 211 | + .await?; | |
| 212 | + manager | |
| 213 | + .drop_index(Index::drop().name("idx_restaurants_area_osm_ref").table(Restaurants::Table).to_owned()) | |
| 214 | + .await?; | |
| 215 | + // Fails if two areas now share a place; that's the honest outcome. | |
| 216 | + manager | |
| 217 | + .create_index( | |
| 218 | + Index::create() | |
| 219 | + .name("idx_restaurants_osm_ref") | |
| 220 | + .table(Restaurants::Table) | |
| 221 | + .col(Restaurants::OsmRef) | |
| 222 | + .unique() | |
| 223 | + .to_owned(), | |
| 224 | + ) | |
| 225 | + .await?; | |
| 226 | + for table in [ | |
| 227 | + Friendships::Table.into_iden(), | |
| 228 | + RegistrationTokenAreas::Table.into_iden(), | |
| 229 | + AreaInvitations::Table.into_iden(), | |
| 230 | + AreaMembers::Table.into_iden(), | |
| 231 | + ] { | |
| 232 | + manager.drop_table(Table::drop().table(table).to_owned()).await?; | |
| 233 | + } | |
| 234 | + Ok(()) | |
| 235 | + } | |
| 236 | +} | |
| 237 | + | |
| 238 | +#[derive(DeriveIden)] | |
| 239 | +enum Users { | |
| 240 | + Table, | |
| 241 | + Id, | |
| 242 | +} | |
| 243 | + | |
| 244 | +#[derive(DeriveIden)] | |
| 245 | +enum Areas { | |
| 246 | + Table, | |
| 247 | + Id, | |
| 248 | +} | |
| 249 | + | |
| 250 | +#[derive(DeriveIden)] | |
| 251 | +enum Restaurants { | |
| 252 | + Table, | |
| 253 | + AreaId, | |
| 254 | + OsmRef, | |
| 255 | +} | |
| 256 | + | |
| 257 | +#[derive(DeriveIden)] | |
| 258 | +enum RegistrationTokens { | |
| 259 | + Table, | |
| 260 | + Id, | |
| 261 | +} | |
| 262 | + | |
| 263 | +#[derive(DeriveIden)] | |
| 264 | +enum AreaMembers { | |
| 265 | + Table, | |
| 266 | + AreaId, | |
| 267 | + UserId, | |
| 268 | + Role, | |
| 269 | + CreatedAt, | |
| 270 | +} | |
| 271 | + | |
| 272 | +#[derive(DeriveIden)] | |
| 273 | +enum AreaInvitations { | |
| 274 | + Table, | |
| 275 | + AreaId, | |
| 276 | + UserId, | |
| 277 | + Role, | |
| 278 | + InvitedBy, | |
| 279 | + CreatedAt, | |
| 280 | +} | |
| 281 | + | |
| 282 | +#[derive(DeriveIden)] | |
| 283 | +enum RegistrationTokenAreas { | |
| 284 | + Table, | |
| 285 | + TokenId, | |
| 286 | + AreaId, | |
| 287 | + Role, | |
| 288 | +} | |
| 289 | + | |
| 290 | +#[derive(DeriveIden)] | |
| 291 | +enum Friendships { | |
| 292 | + Table, | |
| 293 | + RequesterId, | |
| 294 | + AddresseeId, | |
| 295 | + Status, | |
| 296 | + CreatedAt, | |
| 297 | + AcceptedAt, | |
| 298 | +} |
added
src/access.rs
+110
−0
| 1 | +//! Who may do what in an area. Areas are members-only: to anyone who isn't a | |
| 2 | +//! member, an area and everything in it (restaurants, lunches, outings) | |
| 3 | +//! answers 404, as though it didn't exist. Members are held to their role | |
| 4 | +//! with 403. See `entities::area_members::Role` for what each role allows. | |
| 5 | +use std::collections::HashMap; | |
| 6 | + | |
| 7 | +use axum::http::StatusCode; | |
| 8 | +use chrono::Utc; | |
| 9 | +use sea_orm::sea_query::OnConflict; | |
| 10 | +use sea_orm::*; | |
| 11 | +use tracing::error; | |
| 12 | +use uuid::Uuid; | |
| 13 | + | |
| 14 | +use crate::entities::area_members::{self, Role}; | |
| 15 | +use crate::entities::{areas, users}; | |
| 16 | + | |
| 17 | +pub async fn role_in(db: &impl ConnectionTrait, area_id: Uuid, user_id: Uuid) -> Result<Option<Role>, DbErr> { | |
| 18 | + Ok(area_members::Entity::find_by_id((area_id, user_id)).one(db).await?.map(|m| m.role)) | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// The user's role in the area, if it's at least `min`. Not a member: 404; | |
| 22 | +/// a member with too little: 403. | |
| 23 | +pub async fn require(db: &impl ConnectionTrait, area_id: Uuid, user_id: Uuid, min: Role) -> Result<Role, StatusCode> { | |
| 24 | + match role_in(db, area_id, user_id).await { | |
| 25 | + Ok(Some(role)) if role >= min => Ok(role), | |
| 26 | + Ok(Some(_)) => Err(StatusCode::FORBIDDEN), | |
| 27 | + Ok(None) => Err(StatusCode::NOT_FOUND), | |
| 28 | + Err(e) => { | |
| 29 | + error!(error = %e, "load membership failed"); | |
| 30 | + Err(StatusCode::INTERNAL_SERVER_ERROR) | |
| 31 | + } | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// Areas the user belongs to, by name, with their role in each. | |
| 36 | +pub async fn member_areas(db: &impl ConnectionTrait, user_id: Uuid) -> Result<Vec<(areas::Model, Role)>, DbErr> { | |
| 37 | + let roles: HashMap<Uuid, Role> = area_members::Entity::find() | |
| 38 | + .filter(area_members::Column::UserId.eq(user_id)) | |
| 39 | + .all(db) | |
| 40 | + .await? | |
| 41 | + .into_iter() | |
| 42 | + .map(|m| (m.area_id, m.role)) | |
| 43 | + .collect(); | |
| 44 | + Ok(areas::Entity::find() | |
| 45 | + .filter(areas::Column::Id.is_in(roles.keys().copied())) | |
| 46 | + .order_by_asc(areas::Column::Name) | |
| 47 | + .all(db) | |
| 48 | + .await? | |
| 49 | + .into_iter() | |
| 50 | + .map(|a| { | |
| 51 | + let role = roles[&a.id]; | |
| 52 | + (a, role) | |
| 53 | + }) | |
| 54 | + .collect()) | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// An area's active members, by username, with roles. | |
| 58 | +pub async fn members(db: &impl ConnectionTrait, area_id: Uuid) -> Result<Vec<(users::Model, Role)>, DbErr> { | |
| 59 | + let roles: HashMap<Uuid, Role> = area_members::Entity::find() | |
| 60 | + .filter(area_members::Column::AreaId.eq(area_id)) | |
| 61 | + .all(db) | |
| 62 | + .await? | |
| 63 | + .into_iter() | |
| 64 | + .map(|m| (m.user_id, m.role)) | |
| 65 | + .collect(); | |
| 66 | + Ok(users::Entity::find() | |
| 67 | + .filter(users::Column::Id.is_in(roles.keys().copied())) | |
| 68 | + .filter(users::Column::IsActive.eq(true)) | |
| 69 | + .order_by_asc(users::Column::Username) | |
| 70 | + .all(db) | |
| 71 | + .await? | |
| 72 | + .into_iter() | |
| 73 | + .map(|u| { | |
| 74 | + let role = roles[&u.id]; | |
| 75 | + (u, role) | |
| 76 | + }) | |
| 77 | + .collect()) | |
| 78 | +} | |
| 79 | + | |
| 80 | +/// Make someone a member with at least `role`. An existing higher role is | |
| 81 | +/// kept: an invite never demotes. | |
| 82 | +pub async fn grant(db: &impl ConnectionTrait, area_id: Uuid, user_id: Uuid, role: Role) -> Result<(), DbErr> { | |
| 83 | + let role = match role_in(db, area_id, user_id).await? { | |
| 84 | + Some(current) => current.max(role), | |
| 85 | + None => role, | |
| 86 | + }; | |
| 87 | + area_members::Entity::insert(area_members::ActiveModel { | |
| 88 | + area_id: Set(area_id), | |
| 89 | + user_id: Set(user_id), | |
| 90 | + role: Set(role), | |
| 91 | + created_at: Set(Utc::now().into()), | |
| 92 | + }) | |
| 93 | + .on_conflict( | |
| 94 | + OnConflict::columns([area_members::Column::AreaId, area_members::Column::UserId]) | |
| 95 | + .update_column(area_members::Column::Role) | |
| 96 | + .to_owned(), | |
| 97 | + ) | |
| 98 | + .exec_without_returning(db) | |
| 99 | + .await?; | |
| 100 | + Ok(()) | |
| 101 | +} | |
| 102 | + | |
| 103 | +/// Admins in the area — an area must always keep at least one. | |
| 104 | +pub async fn admin_count(db: &impl ConnectionTrait, area_id: Uuid) -> Result<u64, DbErr> { | |
| 105 | + area_members::Entity::find() | |
| 106 | + .filter(area_members::Column::AreaId.eq(area_id)) | |
| 107 | + .filter(area_members::Column::Role.eq(Role::Admin)) | |
| 108 | + .count(db) | |
| 109 | + .await | |
| 110 | +} |
modified
src/bin/webservice.rs
+22
−4
Click to load diff…
added
src/entities/area_invitations.rs
+22
−0
Click to load diff…
added
src/entities/area_members.rs
+54
−0
Click to load diff…
modified
src/entities/areas.rs
+1
−1
Click to load diff…
added
src/entities/friendships.rs
+30
−0
Click to load diff…
modified
src/entities/mod.rs
+6
−1
Click to load diff…
modified
src/entities/prelude.rs
+4
−0
Click to load diff…
added
src/entities/registration_token_areas.rs
+20
−0
Click to load diff…
modified
src/entities/restaurants.rs
+2
−2
Click to load diff…
added
src/friends.rs
+116
−0
Click to load diff…
modified
src/handlers/areas.rs
+47
−30
Click to load diff…
modified
src/handlers/auth.rs
+102
−11
Click to load diff…
modified
src/handlers/cuisines.rs
+29
−22
Click to load diff…
added
src/handlers/friends.rs
+146
−0
Click to load diff…
modified
src/handlers/lunch.rs
+90
−18
Click to load diff…
added
src/handlers/members.rs
+291
−0
Click to load diff…
modified
src/handlers/mod.rs
+2
−0
Click to load diff…
modified
src/handlers/outings.rs
+26
−20
Click to load diff…
modified
src/handlers/places.rs
+25
−10
Click to load diff…
modified
src/handlers/restaurants.rs
+41
−23
Click to load diff…
modified
src/lib.rs
+2
−0
Click to load diff…
modified
templates/areas/_cards.html
+2
−2
Click to load diff…
added
templates/areas/members.html
+125
−0
Click to load diff…
modified
templates/areas/new.html
+1
−1
Click to load diff…
modified
templates/areas/show.html
+17
−3
Click to load diff…
modified
templates/auth/invites.html
+22
−2
Click to load diff…
added
templates/friends.html
+91
−0
Click to load diff…
modified
templates/index.html
+21
−0
Click to load diff…
modified
templates/lunch/history.html
+2
−0
Click to load diff…
modified
templates/lunch/plan.html
+19
−4
Click to load diff…
modified
templates/restaurants/new.html
+2
−1
Click to load diff…
modified
templates/restaurants/show.html
+3
−0
Click to load diff…
modified
templates/root.html
+6
−0
Click to load diff…
modified
tests/templates.rs
+98
−0
Click to load diff…
Parents: 6dd7389