tetratto-core 16.0.1

The core behind Tetratto
Documentation
use crate::model::{
    auth::{User, UserWarning},
    economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, UserAd, UserAdSize},
    permissions::FinePermission,
    Error, Result,
};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
use tetratto_shared::unix_epoch_timestamp;

impl DataManager {
    /// Get a [`UserAd`] from an SQL row.
    pub(crate) fn get_ad_from_row(x: &PostgresRow) -> UserAd {
        UserAd {
            id: get!(x->0(i64)) as usize,
            created: get!(x->1(i64)) as usize,
            owner: get!(x->2(i64)) as usize,
            upload_id: get!(x->3(i64)) as usize,
            target: get!(x->4(String)),
            last_charge_time: get!(x->5(i64)) as usize,
            is_running: get!(x->6(i32)) as i8 == 1,
            size: serde_json::from_str(&get!(x->7(String))).unwrap(),
        }
    }

    auto_method!(get_ad_by_id(usize as i64)@get_ad_from_row -> "SELECT * FROM ads WHERE id = $1" --name="ad" --returns=UserAd --cache-key-tmpl="atto.ad:{}");

    /// Get all ads by user.
    ///
    /// # Arguments
    /// * `id` - the ID of the user to fetch ads for
    /// * `batch` - the limit of items in each page
    /// * `page` - the page number
    pub async fn get_ads_by_user(
        &self,
        id: usize,
        batch: usize,
        page: usize,
    ) -> Result<Vec<UserAd>> {
        let conn = match self.0.connect().await {
            Ok(c) => c,
            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
        };

        let res = query_rows!(
            &conn,
            "SELECT * FROM ads WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
            &[&(id as i64), &(batch as i64), &((page * batch) as i64)],
            |x| { Self::get_ad_from_row(x) }
        );

        if res.is_err() {
            return Err(Error::GeneralNotFound("ad".to_string()));
        }

        Ok(res.unwrap())
    }

    /// Disable all ads by the given user.
    ///
    /// # Arguments
    /// * `id` - the ID of the user to kill ads from
    pub async fn stop_all_ads_by_user(&self, id: usize) -> Result<Vec<UserAd>> {
        let conn = match self.0.connect().await {
            Ok(c) => c,
            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
        };

        let res = query_rows!(
            &conn,
            "UPDATE ads SET is_running = 0 WHERE owner = $1",
            &[&(id as i64)],
            |x| { Self::get_ad_from_row(x) }
        );

        if let Err(e) = res {
            return Err(Error::DatabaseError(e.to_string()));
        }

        Ok(res.unwrap())
    }

    /// Create a new ad in the database.
    ///
    /// # Arguments
    /// * `data` - a mock [`UserAd`] object to insert
    pub async fn create_ad(&self, data: UserAd) -> Result<UserAd> {
        // check values
        if data.target.len() < 2 {
            return Err(Error::DataTooShort("description".to_string()));
        } else if data.target.len() > 256 {
            return Err(Error::DataTooLong("description".to_string()));
        }

        // charge for first day
        if data.is_running {
            self.create_transfer(
                &mut CoinTransfer::new(
                    data.owner,
                    self.0.0.system_user,
                    Self::AD_RUN_CHARGE,
                    CoinTransferMethod::Transfer,
                    CoinTransferSource::AdCharge,
                ),
                true,
            )
            .await?;
        }

        // ...
        let conn = match self.0.connect().await {
            Ok(c) => c,
            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
        };

        let res = execute!(
            &conn,
            "INSERT INTO ads VALUES (DEFAULT, $1, $2, $3, $4, $5, $6, $7)",
            params![
                &(data.created as i64),
                &(data.owner as i64),
                &(data.upload_id as i64),
                &data.target,
                &(data.last_charge_time as i64),
                &if data.is_running { 1 } else { 0 },
                &serde_json::to_string(&data.size).unwrap()
            ]
        );

        if let Err(e) = res {
            return Err(Error::DatabaseError(e.to_string()));
        }

        Ok(data)
    }

    pub async fn delete_ad(&self, id: usize, user: &User) -> Result<()> {
        let ad = self.get_ad_by_id(id).await?;

        // check user permission
        if user.id != ad.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
            return Err(Error::NotAllowed);
        }

        // remove upload
        if let Err(e) = self.2.delete_upload(ad.upload_id).await {
            return Err(Error::MiscError(e.to_string()));
        }

        // ...
        let conn = match self.0.connect().await {
            Ok(c) => c,
            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
        };

        let res = execute!(&conn, "DELETE FROM ads WHERE id = $1", &[&(id as i64)]);

        if let Err(e) = res {
            return Err(Error::DatabaseError(e.to_string()));
        }

        // ...
        self.0.1.remove(format!("atto.ad:{}", id)).await;
        Ok(())
    }

    /// Pull a random running ad.
    pub async fn random_ad(&self, size: UserAdSize) -> Result<UserAd> {
        let conn = match self.0.connect().await {
            Ok(c) => c,
            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
        };

        let res = query_row!(
            &conn,
            "SELECT * FROM ads WHERE is_running = 1 AND size = $1 ORDER BY RANDOM() DESC LIMIT 1",
            &[&serde_json::to_string(&size).unwrap()],
            |x| { Ok(Self::get_ad_from_row(x)) }
        );

        if res.is_err() {
            return Err(Error::GeneralNotFound("ad".to_string()));
        }

        Ok(res.unwrap())
    }

    const MINIMUM_DELTA_FOR_CHARGE: usize = 604_800_000; // 7 days
    /// The amount charged to a [`UserAd`] owner each day the ad is running (and is pulled from the pool).
    pub const AD_RUN_CHARGE: i32 = 25;
    /// The amount charged to a [`UserAd`] owner each time the ad is clicked.
    pub const AD_CLICK_CHARGE: i32 = 2;

    /// Get a random ad and check if the ad owner needs to be charged for this period.
    pub async fn random_ad_charged(&self, size: UserAdSize) -> Result<UserAd> {
        let ad = self.random_ad(size).await?;

        let now = unix_epoch_timestamp();
        let delta = now - ad.last_charge_time;

        if delta >= Self::MINIMUM_DELTA_FOR_CHARGE {
            if let Err(e) = self
                .create_transfer(
                    &mut CoinTransfer::new(
                        ad.owner,
                        self.0.0.system_user,
                        Self::AD_RUN_CHARGE,
                        CoinTransferMethod::Transfer,
                        CoinTransferSource::AdCharge,
                    ),
                    true,
                )
                .await
            {
                // boo user cannot afford to keep running their ads
                self.stop_all_ads_by_user(ad.owner).await?;
                return Err(e);
            };

            self.update_ad_last_charge_time(ad.id, now as i64).await?;
        }

        Ok(ad)
    }

    /// Handle a click on an ad from the given host.
    ///
    /// Hosts are just the ID of the user that is embedding the ad on their page.
    pub async fn ad_click(&self, host: usize, ad: usize, user: Option<User>) -> Result<String> {
        let ad = self.get_ad_by_id(ad).await?;

        if let Some(ref ua) = user {
            if ua.id == host {
                self.create_user_warning(
                    UserWarning::new(
                        ua.id,
                        self.0.0.system_user,
                        "Automated warning: do not click on ads on your own site! This incident has been reported.".to_string()
                    )
                ).await?;

                return Ok(ad.target);
            }
        }

        // create click transfer
        if let Err(e) = self
            .create_transfer(
                &mut CoinTransfer::new(
                    ad.owner,
                    host,
                    Self::AD_CLICK_CHARGE,
                    CoinTransferMethod::Transfer,
                    CoinTransferSource::AdClick,
                ),
                true,
            )
            .await
        {
            self.stop_all_ads_by_user(ad.owner).await?;
            return Err(e);
        }

        // return
        Ok(ad.target)
    }

    auto_method!(update_ad_is_running(i32)@get_ad_by_id:FinePermission::MANAGE_USERS; -> "UPDATE ads SET is_running = $1 WHERE id = $2" --cache-key-tmpl="atto.ad:{}");
    auto_method!(update_ad_last_charge_time(i64) -> "UPDATE ads SET last_charge_time = $1 WHERE id = $2" --cache-key-tmpl="atto.ad:{}");
}