rok-cli 0.3.2

Developer CLI for rok-based Axum applications
use super::{write_file, Scaffold, ScaffoldArgs, ScaffoldResult};
use anyhow::Result;

pub struct NewsletterScaffold;

impl Scaffold for NewsletterScaffold {
    fn name(&self) -> &'static str {
        "newsletter"
    }
    fn description(&self) -> &'static str {
        "Newsletter: subscriber model, double opt-in, campaigns, send job, unsubscribe"
    }

    fn generate(&self, args: &ScaffoldArgs) -> Result<ScaffoldResult> {
        let mut r = ScaffoldResult::default();
        let d = args.dry_run;
        write_file(
            &mut r,
            "src/app/controllers/newsletter_controller.rs",
            CONTROLLER,
            d,
        )?;
        write_file(&mut r, "src/app/jobs/send_campaign_job.rs", JOB, d)?;
        write_file(
            &mut r,
            "migrations/create_newsletter_tables.sql",
            MIGRATION,
            d,
        )?;
        r.warnings.push("Register /newsletter routes".into());
        r.warnings
            .push("Configure MAIL_* env vars for sending".into());
        Ok(r)
    }
}

const CONTROLLER: &str = r#"use axum::{extract::{Path, Query}, response::IntoResponse, Json};
use rok_auth::axum::Response;
use serde::Deserialize;

#[derive(Deserialize)]
pub struct SubscribeRequest { pub email: String, pub name: Option<String> }

pub async fn subscribe(Json(req): Json<SubscribeRequest>) -> impl IntoResponse {
    // TODO: create subscriber with confirmed_at = NULL, send confirmation email
    Response::json(serde_json::json!({ "message": "Check your email to confirm" }))
}

pub async fn confirm(Path(token): Path<String>) -> impl IntoResponse {
    // TODO: set confirmed_at = now() WHERE confirm_token = token
    Response::json(serde_json::json!({ "message": "Subscription confirmed" }))
}

pub async fn unsubscribe(Path(token): Path<String>) -> impl IntoResponse {
    // TODO: set unsubscribed_at = now() WHERE unsubscribe_token = token
    Response::json(serde_json::json!({ "message": "Unsubscribed successfully" }))
}
"#;

const JOB: &str = r#"use rok_queue::Job;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct SendCampaignJob {
    pub campaign_id: i64,
    pub batch_size: usize,
    pub offset: usize,
}

#[async_trait::async_trait]
impl Job for SendCampaignJob {
    const QUEUE: &'static str = "newsletters";

    async fn handle(&self) -> anyhow::Result<()> {
        // TODO: fetch subscribers batch, render template, send via rok-mail
        Ok(())
    }
}
"#;

const MIGRATION: &str = r#"CREATE TABLE newsletter_subscribers (
    id                BIGSERIAL PRIMARY KEY,
    email             TEXT NOT NULL UNIQUE,
    name              TEXT,
    confirmed_at      TIMESTAMPTZ,
    unsubscribed_at   TIMESTAMPTZ,
    confirm_token     TEXT,
    unsubscribe_token TEXT NOT NULL,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE newsletter_campaigns (
    id          BIGSERIAL PRIMARY KEY,
    subject     TEXT NOT NULL,
    body_html   TEXT NOT NULL,
    body_text   TEXT,
    status      TEXT NOT NULL DEFAULT 'draft',
    scheduled_at TIMESTAMPTZ,
    sent_at     TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
"#;