Skip to main content

max / makenotwork

Add AI disclosure at project level (migration 110) Projects now have ai_tier (handmade/assisted/generated) and ai_disclosure fields. Set during project creation and editable in the basics wizard step. New items inherit the project's AI tier by default. Shown as radio group with conditional disclosure text field for the assisted tier. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-10 22:54 UTC
Commit: 007014af723b16fd5a4e84b8cb8c013579d5194c
Parent: 5aa9fd9
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 %}