max / makenotwork
8 files changed,
+137 insertions,
-9 deletions
| @@ -0,0 +1,3 @@ | |||
| 1 | + | -- Add AI disclosure at the project level. Items inherit from project on creation. | |
| 2 | + | ALTER TABLE projects ADD COLUMN ai_tier TEXT NOT NULL DEFAULT 'handmade'; | |
| 3 | + | ALTER TABLE projects ADD COLUMN ai_disclosure TEXT; |
| @@ -44,6 +44,10 @@ pub struct DbProject { | |||
| 44 | 44 | pub pwyw_min_cents: Option<i32>, | |
| 45 | 45 | /// Whether this project requires license verification (phone-home) on content access. | |
| 46 | 46 | pub license_verification_enabled: bool, | |
| 47 | + | /// AI content tier: handmade, assisted, or generated. | |
| 48 | + | pub ai_tier: super::super::AiTier, | |
| 49 | + | /// Required disclosure text when ai_tier is assisted. | |
| 50 | + | pub ai_disclosure: Option<String>, | |
| 47 | 51 | } | |
| 48 | 52 | ||
| 49 | 53 | /// A git repository tracked on disk, optionally linked to a project. |
| @@ -294,6 +294,32 @@ pub async fn update_project_image_url( | |||
| 294 | 294 | Ok(()) | |
| 295 | 295 | } | |
| 296 | 296 | ||
| 297 | + | /// Update a project's AI content tier and disclosure. | |
| 298 | + | #[tracing::instrument(skip_all)] | |
| 299 | + | pub async fn update_project_ai_tier( | |
| 300 | + | pool: &PgPool, | |
| 301 | + | id: ProjectId, | |
| 302 | + | user_id: UserId, | |
| 303 | + | ai_tier: super::AiTier, | |
| 304 | + | ai_disclosure: Option<&str>, | |
| 305 | + | ) -> Result<()> { | |
| 306 | + | sqlx::query( | |
| 307 | + | r#" | |
| 308 | + | UPDATE projects | |
| 309 | + | SET ai_tier = $3, ai_disclosure = $4, updated_at = NOW() | |
| 310 | + | WHERE id = $1 AND user_id = $2 | |
| 311 | + | "#, | |
| 312 | + | ) | |
| 313 | + | .bind(id) | |
| 314 | + | .bind(user_id) | |
| 315 | + | .bind(ai_tier) | |
| 316 | + | .bind(ai_disclosure) | |
| 317 | + | .execute(pool) | |
| 318 | + | .await?; | |
| 319 | + | ||
| 320 | + | Ok(()) | |
| 321 | + | } | |
| 322 | + | ||
| 297 | 323 | /// Update a project's pricing model, price, and PWYW minimum. | |
| 298 | 324 | #[tracing::instrument(skip_all)] | |
| 299 | 325 | pub async fn update_project_pricing( |
| @@ -86,17 +86,14 @@ pub(in crate::routes::api) async fn create_item( | |||
| 86 | 86 | ))); | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | // Validate AI tier disclosure | |
| 90 | - | let ai_tier = req.ai_tier.unwrap_or(AiTier::Handmade); | |
| 89 | + | // Inherit AI tier from project if not specified on the item | |
| 90 | + | let ai_tier = req.ai_tier.unwrap_or(project.ai_tier); | |
| 91 | 91 | let ai_disclosure = match ai_tier { | |
| 92 | 92 | AiTier::Assisted => { | |
| 93 | - | let text = req.ai_disclosure.as_deref().unwrap_or("").trim(); | |
| 94 | - | if text.is_empty() { | |
| 95 | - | return Err(AppError::Validation( | |
| 96 | - | "AI disclosure is required for Assisted tier items".to_string(), | |
| 97 | - | )); | |
| 98 | - | } | |
| 99 | - | Some(text.to_string()) | |
| 93 | + | let text = req.ai_disclosure.as_deref() | |
| 94 | + | .or(project.ai_disclosure.as_deref()) | |
| 95 | + | .unwrap_or("").trim(); | |
| 96 | + | if text.is_empty() { None } else { Some(text.to_string()) } | |
| 100 | 97 | } | |
| 101 | 98 | _ => None, | |
| 102 | 99 | }; |
| @@ -94,6 +94,8 @@ pub struct BasicsForm { | |||
| 94 | 94 | pub features: Vec<String>, | |
| 95 | 95 | pub category: Option<String>, | |
| 96 | 96 | pub description: Option<String>, | |
| 97 | + | pub ai_tier: Option<String>, | |
| 98 | + | pub ai_disclosure: Option<String>, | |
| 97 | 99 | } | |
| 98 | 100 | ||
| 99 | 101 | /// POST /dashboard/new-project/step/basics — create project, return step 2. | |
| @@ -147,6 +149,11 @@ pub async fn step_basics_create( | |||
| 147 | 149 | db::projects::set_project_category(&state.db, project.id, user.id, Some(cat_id)).await?; | |
| 148 | 150 | } | |
| 149 | 151 | ||
| 152 | + | // Save AI tier | |
| 153 | + | let ai_tier = form.ai_tier.as_deref().unwrap_or("handmade").parse::<db::AiTier>().unwrap_or(db::AiTier::Handmade); | |
| 154 | + | let ai_disclosure = if ai_tier == db::AiTier::Assisted { form.ai_disclosure.as_deref() } else { None }; | |
| 155 | + | db::projects::update_project_ai_tier(&state.db, project.id, user.id, ai_tier, ai_disclosure).await?; | |
| 156 | + | ||
| 150 | 157 | // Create default mailing lists (non-blocking) | |
| 151 | 158 | if let Err(e) = db::mailing_lists::create_default_lists(&state.db, project.id, &form.title).await { | |
| 152 | 159 | tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists"); | |
| @@ -189,6 +196,7 @@ pub async fn step_save( | |||
| 189 | 196 | let project = verify_wizard_access(&state, &user, &slug).await?; | |
| 190 | 197 | ||
| 191 | 198 | match step.as_str() { | |
| 199 | + | "basics" => save_basics(&state, &user, &project, &form).await?, | |
| 192 | 200 | "appearance" => save_appearance(&state, &project, &form).await?, | |
| 193 | 201 | "monetization" => save_monetization(&state, &user, &project, &form).await?, | |
| 194 | 202 | "first-content" => save_first_content(&state, &project, &form).await?, | |
| @@ -204,6 +212,23 @@ pub async fn step_save( | |||
| 204 | 212 | // Step save handlers | |
| 205 | 213 | // ============================================================================= | |
| 206 | 214 | ||
| 215 | + | async fn save_basics( | |
| 216 | + | state: &AppState, | |
| 217 | + | user: &crate::auth::SessionUser, | |
| 218 | + | project: &db::DbProject, | |
| 219 | + | form: &HashMap<String, String>, | |
| 220 | + | ) -> Result<()> { | |
| 221 | + | let ai_tier = form.get("ai_tier").map(|s| s.as_str()).unwrap_or("handmade") | |
| 222 | + | .parse::<db::AiTier>().unwrap_or(db::AiTier::Handmade); | |
| 223 | + | let ai_disclosure = if ai_tier == db::AiTier::Assisted { | |
| 224 | + | form.get("ai_disclosure").map(|s| s.as_str()) | |
| 225 | + | } else { | |
| 226 | + | None | |
| 227 | + | }; | |
| 228 | + | db::projects::update_project_ai_tier(&state.db, project.id, user.id, ai_tier, ai_disclosure).await?; | |
| 229 | + | Ok(()) | |
| 230 | + | } | |
| 231 | + | ||
| 207 | 232 | async fn save_appearance( | |
| 208 | 233 | state: &AppState, | |
| 209 | 234 | project: &db::DbProject, | |
| @@ -400,6 +425,8 @@ async fn render_step( | |||
| 400 | 425 | category_name: db::categories::get_project_category_name(&state.db, project.id) | |
| 401 | 426 | .await? | |
| 402 | 427 | .unwrap_or_default(), | |
| 428 | + | ai_tier: project.ai_tier.to_string(), | |
| 429 | + | ai_disclosure: project.ai_disclosure.clone().unwrap_or_default(), | |
| 403 | 430 | } | |
| 404 | 431 | .into_response()) | |
| 405 | 432 | } |
| @@ -312,6 +312,8 @@ pub struct WizardProjectBasicsTemplate { | |||
| 312 | 312 | pub features: Vec<String>, | |
| 313 | 313 | pub description: String, | |
| 314 | 314 | pub category_name: String, | |
| 315 | + | pub ai_tier: String, | |
| 316 | + | pub ai_disclosure: String, | |
| 315 | 317 | } | |
| 316 | 318 | ||
| 317 | 319 | /// Wizard step partial: project appearance (cover image upload). |
| @@ -56,6 +56,34 @@ | |||
| 56 | 56 | placeholder="Describe your project...">{{ description }}</textarea> | |
| 57 | 57 | </div> | |
| 58 | 58 | ||
| 59 | + | <div class="form-group"> | |
| 60 | + | <label>AI Disclosure</label> | |
| 61 | + | <div class="hint" style="margin-bottom: 0.5rem;">How was AI used in creating this project's content?</div> | |
| 62 | + | <div style="display: flex; flex-direction: column; gap: 0.5rem;"> | |
| 63 | + | <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"> | |
| 64 | + | <input type="radio" name="ai_tier" value="handmade" | |
| 65 | + | {% if ai_tier == "handmade" || ai_tier == "" %}checked{% endif %}> | |
| 66 | + | <span><strong>Handmade</strong> — no AI tools used in content creation</span> | |
| 67 | + | </label> | |
| 68 | + | <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"> | |
| 69 | + | <input type="radio" name="ai_tier" value="assisted" | |
| 70 | + | {% if ai_tier == "assisted" %}checked{% endif %}> | |
| 71 | + | <span><strong>AI-Assisted</strong> — AI tools used as part of the creative process</span> | |
| 72 | + | </label> | |
| 73 | + | <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"> | |
| 74 | + | <input type="radio" name="ai_tier" value="generated" | |
| 75 | + | {% if ai_tier == "generated" %}checked{% endif %}> | |
| 76 | + | <span><strong>AI-Generated</strong> — content primarily generated by AI</span> | |
| 77 | + | </label> | |
| 78 | + | </div> | |
| 79 | + | </div> | |
| 80 | + | ||
| 81 | + | <div class="form-group" id="ai-disclosure-group" style="{% if ai_tier != "assisted" %}display: none;{% endif %}"> | |
| 82 | + | <label for="wiz-ai-disclosure">Disclosure Details</label> | |
| 83 | + | <textarea id="wiz-ai-disclosure" name="ai_disclosure" rows="2" | |
| 84 | + | placeholder="Describe how AI was used (e.g., code suggestions, image generation, etc.)">{{ ai_disclosure }}</textarea> | |
| 85 | + | </div> | |
| 86 | + | ||
| 59 | 87 | <div class="wizard-actions"> | |
| 60 | 88 | <a href="/dashboard" class="secondary">Cancel</a> | |
| 61 | 89 | <button type="submit" class="primary">Continue</button> | |
| @@ -120,5 +148,13 @@ | |||
| 120 | 148 | input.addEventListener('blur', function() { | |
| 121 | 149 | setTimeout(function() { dropdown.classList.remove('open'); }, 150); | |
| 122 | 150 | }); | |
| 151 | + | ||
| 152 | + | // AI tier toggle | |
| 153 | + | document.querySelectorAll('input[name="ai_tier"]').forEach(function(r) { | |
| 154 | + | r.addEventListener('change', function() { | |
| 155 | + | var group = document.getElementById('ai-disclosure-group'); | |
| 156 | + | group.style.display = this.value === 'assisted' ? '' : 'none'; | |
| 157 | + | }); | |
| 158 | + | }); | |
| 123 | 159 | })(); | |
| 124 | 160 | </script> |
| @@ -76,6 +76,31 @@ | |||
| 76 | 76 | placeholder="Describe your project..."></textarea> | |
| 77 | 77 | </div> | |
| 78 | 78 | ||
| 79 | + | <div class="form-group"> | |
| 80 | + | <label>AI Disclosure</label> | |
| 81 | + | <div class="hint" style="margin-bottom: 0.5rem;">How was AI used in creating this project's content?</div> | |
| 82 | + | <div style="display: flex; flex-direction: column; gap: 0.5rem;"> | |
| 83 | + | <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"> | |
| 84 | + | <input type="radio" name="ai_tier" value="handmade" checked> | |
| 85 | + | <span><strong>Handmade</strong> — no AI tools used in content creation</span> | |
| 86 | + | </label> | |
| 87 | + | <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"> | |
| 88 | + | <input type="radio" name="ai_tier" value="assisted"> | |
| 89 | + | <span><strong>AI-Assisted</strong> — AI tools used as part of the creative process</span> | |
| 90 | + | </label> | |
| 91 | + | <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"> | |
| 92 | + | <input type="radio" name="ai_tier" value="generated"> | |
| 93 | + | <span><strong>AI-Generated</strong> — content primarily generated by AI</span> | |
| 94 | + | </label> | |
| 95 | + | </div> | |
| 96 | + | </div> | |
| 97 | + | ||
| 98 | + | <div class="form-group" id="ai-disclosure-group" style="display: none;"> | |
| 99 | + | <label for="wiz-ai-disclosure">Disclosure Details</label> | |
| 100 | + | <textarea id="wiz-ai-disclosure" name="ai_disclosure" rows="2" | |
| 101 | + | placeholder="Describe how AI was used..."></textarea> | |
| 102 | + | </div> | |
| 103 | + | ||
| 79 | 104 | <div class="wizard-actions"> | |
| 80 | 105 | <a href="/dashboard" class="secondary">Cancel</a> | |
| 81 | 106 | <button type="submit" class="primary">Continue</button> | |
| @@ -147,6 +172,14 @@ | |||
| 147 | 172 | input.addEventListener('blur', function() { | |
| 148 | 173 | setTimeout(function() { dropdown.classList.remove('open'); }, 150); | |
| 149 | 174 | }); | |
| 175 | + | ||
| 176 | + | // AI tier toggle | |
| 177 | + | document.querySelectorAll('input[name="ai_tier"]').forEach(function(r) { | |
| 178 | + | r.addEventListener('change', function() { | |
| 179 | + | var group = document.getElementById('ai-disclosure-group'); | |
| 180 | + | group.style.display = this.value === 'assisted' ? '' : 'none'; | |
| 181 | + | }); | |
| 182 | + | }); | |
| 150 | 183 | })(); | |
| 151 | 184 | </script> | |
| 152 | 185 | {% endblock %} |