Members-only areas with roles, invitations, friends, contributed-by

aecd85b · Gabriel Morell · 2026-10-28 20:47

54 files +1930 -158
Message
{commit_body(@commit)}

Files changed

modified README.md
+28 −0
@@ -67,6 +67,34 @@ Registration is invite-only by default. Set `REGISTRATION_ENABLED=true` to let
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,8 +7,8 @@
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,7 +98,9 @@ function init(el) {
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,6 +5,7 @@ mod m20261028_000001_create_lunch_tables;
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,6 +18,7 @@ impl MigratorTrait for Migrator {
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
@@ -0,0 +1,298 @@
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
@@ -0,0 +1,110 @@
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