11.2 KiB · text History 7a00c02
//! Collaboration: areas get members with roles, people can be invited into
//! areas (existing users accept a pending invitation; sign-up invites carry
//! area grants applied on registration), and users can friend each other.
//!
//! Areas become members-only, so an OSM place is now unique per area rather
//! than globally — two private areas can both list the same Dunkin' — and
//! area names stop being globally unique: someone else's private "Boston"
//! mustn't block (or reveal itself to) yours.
//!
//! Existing data: each area's creator becomes its admin and every existing
//! user an editor of every existing area (admin, where the area has no
//! recorded creator), so nobody loses access to anything on upgrade.
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

fn created_at<T: IntoIden>(col: T) -> ColumnDef {
    ColumnDef::new(col).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()).to_owned()
}

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(AreaMembers::Table)
                    .if_not_exists()
                    .col(ColumnDef::new(AreaMembers::AreaId).uuid().not_null())
                    .col(ColumnDef::new(AreaMembers::UserId).uuid().not_null())
                    // voter | editor | admin
                    .col(ColumnDef::new(AreaMembers::Role).string_len(16).not_null())
                    .col(created_at(AreaMembers::CreatedAt))
                    .primary_key(Index::create().col(AreaMembers::AreaId).col(AreaMembers::UserId))
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_area_members_area_id")
                            .from(AreaMembers::Table, AreaMembers::AreaId)
                            .to(Areas::Table, Areas::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_area_members_user_id")
                            .from(AreaMembers::Table, AreaMembers::UserId)
                            .to(Users::Table, Users::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .to_owned(),
            )
            .await?;
        manager
            .create_index(
                Index::create()
                    .name("idx_area_members_user_id")
                    .table(AreaMembers::Table)
                    .col(AreaMembers::UserId)
                    .to_owned(),
            )
            .await?;

        // An existing user invited into an area, until they accept or decline.
        manager
            .create_table(
                Table::create()
                    .table(AreaInvitations::Table)
                    .if_not_exists()
                    .col(ColumnDef::new(AreaInvitations::AreaId).uuid().not_null())
                    .col(ColumnDef::new(AreaInvitations::UserId).uuid().not_null())
                    .col(ColumnDef::new(AreaInvitations::Role).string_len(16).not_null())
                    .col(ColumnDef::new(AreaInvitations::InvitedBy).uuid().null())
                    .col(created_at(AreaInvitations::CreatedAt))
                    .primary_key(Index::create().col(AreaInvitations::AreaId).col(AreaInvitations::UserId))
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_area_invitations_area_id")
                            .from(AreaInvitations::Table, AreaInvitations::AreaId)
                            .to(Areas::Table, Areas::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_area_invitations_user_id")
                            .from(AreaInvitations::Table, AreaInvitations::UserId)
                            .to(Users::Table, Users::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_area_invitations_invited_by")
                            .from(AreaInvitations::Table, AreaInvitations::InvitedBy)
                            .to(Users::Table, Users::Id)
                            .on_delete(ForeignKeyAction::SetNull),
                    )
                    .to_owned(),
            )
            .await?;

        // Areas a sign-up invite grants, applied when it's used.
        manager
            .create_table(
                Table::create()
                    .table(RegistrationTokenAreas::Table)
                    .if_not_exists()
                    .col(ColumnDef::new(RegistrationTokenAreas::TokenId).uuid().not_null())
                    .col(ColumnDef::new(RegistrationTokenAreas::AreaId).uuid().not_null())
                    .col(ColumnDef::new(RegistrationTokenAreas::Role).string_len(16).not_null())
                    .primary_key(
                        Index::create()
                            .col(RegistrationTokenAreas::TokenId)
                            .col(RegistrationTokenAreas::AreaId),
                    )
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_registration_token_areas_token_id")
                            .from(RegistrationTokenAreas::Table, RegistrationTokenAreas::TokenId)
                            .to(RegistrationTokens::Table, RegistrationTokens::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_registration_token_areas_area_id")
                            .from(RegistrationTokenAreas::Table, RegistrationTokenAreas::AreaId)
                            .to(Areas::Table, Areas::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .to_owned(),
            )
            .await?;

        // One row per pair, in the direction it was asked. Friends are the
        // accepted rows with you on either side.
        manager
            .create_table(
                Table::create()
                    .table(Friendships::Table)
                    .if_not_exists()
                    .col(ColumnDef::new(Friendships::RequesterId).uuid().not_null())
                    .col(ColumnDef::new(Friendships::AddresseeId).uuid().not_null())
                    // pending | accepted
                    .col(ColumnDef::new(Friendships::Status).string_len(16).not_null())
                    .col(created_at(Friendships::CreatedAt))
                    .col(ColumnDef::new(Friendships::AcceptedAt).timestamp_with_time_zone().null())
                    .primary_key(Index::create().col(Friendships::RequesterId).col(Friendships::AddresseeId))
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_friendships_requester_id")
                            .from(Friendships::Table, Friendships::RequesterId)
                            .to(Users::Table, Users::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_friendships_addressee_id")
                            .from(Friendships::Table, Friendships::AddresseeId)
                            .to(Users::Table, Users::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .to_owned(),
            )
            .await?;
        manager
            .create_index(
                Index::create()
                    .name("idx_friendships_addressee_id")
                    .table(Friendships::Table)
                    .col(Friendships::AddresseeId)
                    .to_owned(),
            )
            .await?;

        // OSM places: unique per area, not globally.
        manager
            .drop_index(Index::drop().name("idx_restaurants_osm_ref").table(Restaurants::Table).to_owned())
            .await?;
        manager
            .create_index(
                Index::create()
                    .name("idx_restaurants_area_osm_ref")
                    .table(Restaurants::Table)
                    .col(Restaurants::AreaId)
                    .col(Restaurants::OsmRef)
                    .unique()
                    .to_owned(),
            )
            .await?;

        let db = manager.get_connection();
        db.execute_unprepared("ALTER TABLE areas DROP CONSTRAINT IF EXISTS areas_name_key").await?;
        db.execute_unprepared(
            "INSERT INTO area_members (area_id, user_id, role)
             SELECT id, created_by, 'admin' FROM areas WHERE created_by IS NOT NULL
             ON CONFLICT DO NOTHING",
        )
        .await?;
        db.execute_unprepared(
            "INSERT INTO area_members (area_id, user_id, role)
             SELECT a.id, u.id, CASE WHEN a.created_by IS NULL THEN 'admin' ELSE 'editor' END
             FROM areas a CROSS JOIN users u
             ON CONFLICT DO NOTHING",
        )
        .await?;
        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .get_connection()
            .execute_unprepared("ALTER TABLE areas ADD CONSTRAINT areas_name_key UNIQUE (name)")
            .await?;
        manager
            .drop_index(Index::drop().name("idx_restaurants_area_osm_ref").table(Restaurants::Table).to_owned())
            .await?;
        // Fails if two areas now share a place; that's the honest outcome.
        manager
            .create_index(
                Index::create()
                    .name("idx_restaurants_osm_ref")
                    .table(Restaurants::Table)
                    .col(Restaurants::OsmRef)
                    .unique()
                    .to_owned(),
            )
            .await?;
        for table in [
            Friendships::Table.into_iden(),
            RegistrationTokenAreas::Table.into_iden(),
            AreaInvitations::Table.into_iden(),
            AreaMembers::Table.into_iden(),
        ] {
            manager.drop_table(Table::drop().table(table).to_owned()).await?;
        }
        Ok(())
    }
}

#[derive(DeriveIden)]
enum Users {
    Table,
    Id,
}

#[derive(DeriveIden)]
enum Areas {
    Table,
    Id,
}

#[derive(DeriveIden)]
enum Restaurants {
    Table,
    AreaId,
    OsmRef,
}

#[derive(DeriveIden)]
enum RegistrationTokens {
    Table,
    Id,
}

#[derive(DeriveIden)]
enum AreaMembers {
    Table,
    AreaId,
    UserId,
    Role,
    CreatedAt,
}

#[derive(DeriveIden)]
enum AreaInvitations {
    Table,
    AreaId,
    UserId,
    Role,
    InvitedBy,
    CreatedAt,
}

#[derive(DeriveIden)]
enum RegistrationTokenAreas {
    Table,
    TokenId,
    AreaId,
    Role,
}

#[derive(DeriveIden)]
enum Friendships {
    Table,
    RequesterId,
    AddresseeId,
    Status,
    CreatedAt,
    AcceptedAt,
}