1use crate::addon::Addon;
2use anyhow::Result;
3use std::path::Path;
4
5pub struct MultitenancyAddon;
6
7impl Addon for MultitenancyAddon {
8 fn name(&self) -> &str {
9 "multitenancy"
10 }
11
12 fn check_prerequisites(&self, project_root: &Path) -> Result<()> {
13 super::check_romance_project(project_root)?;
14 super::check_auth_exists(project_root)
15 }
16
17 fn is_already_installed(&self, project_root: &Path) -> bool {
18 project_root.join("backend/src/tenant.rs").exists()
19 }
20
21 fn install(&self, project_root: &Path) -> Result<()> {
22 install_multitenancy(project_root)
23 }
24
25 fn uninstall(&self, project_root: &Path) -> Result<()> {
26 uninstall_multitenancy(project_root)
27 }
28
29 fn dependencies(&self) -> Vec<&str> {
30 vec!["auth"]
31 }
32}
33
34fn install_multitenancy(project_root: &Path) -> Result<()> {
35 use crate::template::TemplateEngine;
36 use crate::utils;
37 use colored::Colorize;
38 use tera::Context;
39
40 println!("{}", "Installing multitenancy...".bold());
41
42 let engine = TemplateEngine::new()?;
43 let ctx = Context::new();
44
45 let content = engine.render("addon/multitenancy/tenant.rs.tera", &ctx)?;
47 utils::write_file(&project_root.join("backend/src/tenant.rs"), &content)?;
48 println!(" {} backend/src/tenant.rs", "create".green());
49
50 let content = engine.render("addon/multitenancy/tenant_model.rs.tera", &ctx)?;
52 utils::write_file(
53 &project_root.join("backend/src/entities/tenant.rs"),
54 &content,
55 )?;
56 println!(" {} backend/src/entities/tenant.rs", "create".green());
57
58 let content = engine.render("addon/multitenancy/tenant_handlers.rs.tera", &ctx)?;
60 utils::write_file(
61 &project_root.join("backend/src/handlers/tenant.rs"),
62 &content,
63 )?;
64 println!(" {} backend/src/handlers/tenant.rs", "create".green());
65
66 let content = engine.render("addon/multitenancy/tenant_routes.rs.tera", &ctx)?;
68 utils::write_file(
69 &project_root.join("backend/src/routes/tenant.rs"),
70 &content,
71 )?;
72 println!(" {} backend/src/routes/tenant.rs", "create".green());
73
74 let ts1 = crate::generator::migration::next_timestamp();
76 let content = engine.render("addon/multitenancy/tenant_migration.rs.tera", &ctx)?;
77 let migration1_module = format!("m{}_create_tenants_table", ts1);
78 utils::write_file(
79 &project_root.join(format!("backend/migration/src/{}.rs", migration1_module)),
80 &content,
81 )?;
82 println!(
83 " {} backend/migration/src/{}.rs",
84 "create".green(),
85 migration1_module
86 );
87
88 let ts2 = crate::generator::migration::next_timestamp();
90 let content = engine.render(
91 "addon/multitenancy/add_tenant_to_users_migration.rs.tera",
92 &ctx,
93 )?;
94 let migration2_module = format!("m{}_add_tenant_id_to_users", ts2);
95 utils::write_file(
96 &project_root.join(format!("backend/migration/src/{}.rs", migration2_module)),
97 &content,
98 )?;
99 println!(
100 " {} backend/migration/src/{}.rs",
101 "create".green(),
102 migration2_module
103 );
104
105 let mods_marker = "// === ROMANCE:MODS ===";
107
108 utils::insert_at_marker(
109 &project_root.join("backend/src/entities/mod.rs"),
110 mods_marker,
111 "pub mod tenant;",
112 )?;
113 utils::insert_at_marker(
114 &project_root.join("backend/src/handlers/mod.rs"),
115 mods_marker,
116 "pub mod tenant;",
117 )?;
118 utils::insert_at_marker(
119 &project_root.join("backend/src/routes/mod.rs"),
120 mods_marker,
121 "pub mod tenant;",
122 )?;
123 utils::insert_at_marker(
124 &project_root.join("backend/src/routes/mod.rs"),
125 "// === ROMANCE:ROUTES ===",
126 " .merge(tenant::router())",
127 )?;
128
129 let lib_path = project_root.join("backend/migration/src/lib.rs");
131 utils::insert_at_marker(
132 &lib_path,
133 "// === ROMANCE:MIGRATION_MODS ===",
134 &format!("mod {};", migration1_module),
135 )?;
136 utils::insert_at_marker(
137 &lib_path,
138 "// === ROMANCE:MIGRATIONS ===",
139 &format!(" Box::new({}::Migration),", migration1_module),
140 )?;
141 utils::insert_at_marker(
142 &lib_path,
143 "// === ROMANCE:MIGRATION_MODS ===",
144 &format!("mod {};", migration2_module),
145 )?;
146 utils::insert_at_marker(
147 &lib_path,
148 "// === ROMANCE:MIGRATIONS ===",
149 &format!(" Box::new({}::Migration),", migration2_module),
150 )?;
151
152 super::add_mod_to_main(project_root, "tenant")?;
154
155 patch_auth_for_multitenancy(project_root)?;
157
158 super::update_feature_flag(project_root, "multitenancy", true)?;
160
161 println!();
162 println!(
163 "{}",
164 "Multitenancy installed successfully!".green().bold()
165 );
166 println!(" All future entities will include tenant_id column.");
167 println!(" Existing entities need manual migration to add tenant_id.");
168 println!(" Tenant admin API: POST/GET /api/tenants (admin-only)");
169 println!();
170 println!("Next steps:");
171 println!(" romance db migrate");
172
173 Ok(())
174}
175
176fn insert_after_first(content: &str, needle: &str, new_line: &str) -> String {
178 let mut result = String::with_capacity(content.len() + new_line.len() + 2);
179 let mut found = false;
180 for line in content.lines() {
181 result.push_str(line);
182 result.push('\n');
183 if !found && line.contains(needle) {
184 result.push_str(new_line);
185 result.push('\n');
186 found = true;
187 }
188 }
189 result
190}
191
192fn insert_before_first(content: &str, needle: &str, block: &str) -> String {
194 let mut result = String::with_capacity(content.len() + block.len());
195 let mut found = false;
196 for line in content.lines() {
197 if !found && line.contains(needle) {
198 result.push_str(block);
199 if !block.ends_with('\n') {
200 result.push('\n');
201 }
202 found = true;
203 }
204 result.push_str(line);
205 result.push('\n');
206 }
207 result
208}
209
210fn patch_auth_for_multitenancy(project_root: &Path) -> Result<()> {
214 use colored::Colorize;
215
216 let auth_path = project_root.join("backend/src/auth.rs");
218 if auth_path.exists() {
219 let content = std::fs::read_to_string(&auth_path)?;
220 if !content.contains("tenant_id") {
221 let mut patched = content;
222
223 patched = insert_after_first(
225 &patched,
226 "pub role: String,",
227 " pub tenant_id: Option<String>,",
228 );
229
230 patched = patched.replace(
232 "pub fn create_token(user_id: Uuid, email: &str, role: &str)",
233 "pub fn create_token(user_id: Uuid, email: &str, role: &str, tenant_id: Option<Uuid>)",
234 );
235
236 patched = insert_after_first(
238 &patched,
239 "role: role.to_string(),",
240 " tenant_id: tenant_id.map(|t| t.to_string()),",
241 );
242
243 std::fs::write(&auth_path, patched)?;
244 println!(
245 " {} backend/src/auth.rs (added tenant_id)",
246 "patch".yellow()
247 );
248 }
249 }
250
251 let user_model_path = project_root.join("backend/src/entities/user.rs");
253 if user_model_path.exists() {
254 let content = std::fs::read_to_string(&user_model_path)?;
255 if !content.contains("tenant_id") {
256 let mut lines: Vec<String> = content.lines().map(String::from).collect();
257 let mut insertions: Vec<(usize, String)> = Vec::new();
258 let mut in_update_user_role = false;
259
260 for (i, line) in lines.iter().enumerate() {
261 if line.contains("pub struct UpdateUserRole") {
262 in_update_user_role = true;
263 }
264 if in_update_user_role && line.trim() == "}" {
265 in_update_user_role = false;
266 }
267 if line.contains("pub role: String,") && !in_update_user_role {
269 insertions.push((i + 1, " pub tenant_id: Uuid,".to_string()));
270 }
271 if line.contains("pub password: String,") {
273 insertions.push((i + 1, " pub tenant_id: Option<Uuid>,".to_string()));
274 }
275 }
276
277 for (idx, new_line) in insertions.into_iter().rev() {
278 lines.insert(idx, new_line);
279 }
280
281 let patched = lines.join("\n") + "\n";
282 std::fs::write(&user_model_path, patched)?;
283 println!(
284 " {} backend/src/entities/user.rs (added tenant_id)",
285 "patch".yellow()
286 );
287 }
288 }
289
290 let auth_handlers_path = project_root.join("backend/src/handlers/auth.rs");
292 if auth_handlers_path.exists() {
293 let content = std::fs::read_to_string(&auth_handlers_path)?;
294 if !content.contains("tenant_id") {
295 let mut patched = content;
296
297 patched = patched.replace(
299 "auth::create_token(created.id, &created.email, &created.role)",
300 "auth::create_token(created.id, &created.email, &created.role, Some(created.tenant_id))",
301 );
302 patched = patched.replace(
303 "auth::create_token(user.id, &user.email, &user.role)",
304 "auth::create_token(user.id, &user.email, &user.role, Some(user.tenant_id))",
305 );
306
307 patched = insert_after_first(
309 &patched,
310 "role: Set(\"user\".to_string()),",
311 " tenant_id: Set(tenant_id),",
312 );
313
314 let tenant_block = "\
316 // Resolve tenant: use provided tenant_id or create a default tenant
317 let tenant_id = if let Some(tid) = input.tenant_id {
318 crate::entities::tenant::Entity::find_by_id(tid)
319 .one(&state.db)
320 .await?
321 .ok_or_else(|| AppError::NotFound(\"Tenant not found\".into()))?;
322 tid
323 } else {
324 let tid = Uuid::new_v4();
325 let tenant_model = crate::entities::tenant::ActiveModel {
326 id: Set(tid),
327 name: Set(format!(\"{}\'s Organization\", input.email)),
328 slug: Set(format!(\"org-{}\", user_id)),
329 created_at: Set(now),
330 updated_at: Set(now),
331 };
332 tenant_model.insert(&state.db).await?;
333 tid
334 };
335
336";
337 patched = insert_before_first(
338 &patched,
339 "let model = user::ActiveModel {",
340 tenant_block,
341 );
342
343 let mut lines: Vec<String> = patched.lines().map(String::from).collect();
345 let mut insertions: Vec<(usize, String)> = Vec::new();
346 for (i, line) in lines.iter().enumerate() {
347 let trimmed = line.trim();
348 if trimmed.starts_with("role:") && trimmed.ends_with(".role,") {
350 let indent: String =
351 line.chars().take_while(|c| c.is_whitespace()).collect();
352 if let Some(var) = trimmed
353 .strip_prefix("role: ")
354 .and_then(|s| s.strip_suffix(".role,"))
355 {
356 insertions.push((
357 i + 1,
358 format!("{}tenant_id: {}.tenant_id,", indent, var),
359 ));
360 }
361 }
362 }
363
364 for (idx, new_line) in insertions.into_iter().rev() {
365 lines.insert(idx, new_line);
366 }
367
368 let patched = lines.join("\n") + "\n";
369 std::fs::write(&auth_handlers_path, patched)?;
370 println!(
371 " {} backend/src/handlers/auth.rs (added tenant_id)",
372 "patch".yellow()
373 );
374 }
375 }
376
377 Ok(())
378}
379
380fn uninstall_multitenancy(project_root: &Path) -> Result<()> {
381 use colored::Colorize;
382
383 println!("{}", "Uninstalling multitenancy...".bold());
384
385 let files_to_remove = [
387 "backend/src/tenant.rs",
388 "backend/src/entities/tenant.rs",
389 "backend/src/handlers/tenant.rs",
390 "backend/src/routes/tenant.rs",
391 ];
392
393 for file in &files_to_remove {
394 if super::remove_file_if_exists(&project_root.join(file))? {
395 println!(" {} {}", "delete".red(), file);
396 }
397 }
398
399 super::remove_mod_from_main(project_root, "tenant")?;
401
402 super::remove_line_from_file(
403 &project_root.join("backend/src/entities/mod.rs"),
404 "pub mod tenant;",
405 )?;
406 super::remove_line_from_file(
407 &project_root.join("backend/src/handlers/mod.rs"),
408 "pub mod tenant;",
409 )?;
410 super::remove_line_from_file(
411 &project_root.join("backend/src/routes/mod.rs"),
412 "pub mod tenant;",
413 )?;
414 super::remove_line_from_file(
415 &project_root.join("backend/src/routes/mod.rs"),
416 ".merge(tenant::router())",
417 )?;
418
419 let auth_path = project_root.join("backend/src/auth.rs");
421 if auth_path.exists() {
422 let content = std::fs::read_to_string(&auth_path)?;
423 if content.contains("tenant_id") {
424 let mut patched = content;
425 patched = patched.replace(" pub tenant_id: Option<String>,\n", "");
427 patched = patched.replace(
429 "pub fn create_token(user_id: Uuid, email: &str, role: &str, tenant_id: Option<Uuid>) -> Result<String>",
430 "pub fn create_token(user_id: Uuid, email: &str, role: &str) -> Result<String>",
431 );
432 patched = patched.replace(
434 " tenant_id: tenant_id.map(|t| t.to_string()),\n",
435 "",
436 );
437 std::fs::write(&auth_path, patched)?;
438 println!(" {} backend/src/auth.rs (removed tenant_id)", "patch".yellow());
439 }
440 }
441
442 let user_model_path = project_root.join("backend/src/entities/user.rs");
444 if user_model_path.exists() {
445 let content = std::fs::read_to_string(&user_model_path)?;
446 if content.contains(" pub tenant_id: Uuid,") {
447 let patched = content
448 .replace(" pub tenant_id: Uuid,\n", "")
449 .replace(" pub tenant_id: Option<Uuid>,\n", "");
450 std::fs::write(&user_model_path, patched)?;
451 println!(
452 " {} backend/src/entities/user.rs (removed tenant_id)",
453 "patch".yellow()
454 );
455 }
456 }
457
458 let auth_handlers_path = project_root.join("backend/src/handlers/auth.rs");
460 if auth_handlers_path.exists() {
461 let content = std::fs::read_to_string(&auth_handlers_path)?;
462 if content.contains("tenant_id") {
463 let mut patched = content;
464 patched = patched.replace(
466 ", Some(created.tenant_id))",
467 ")",
468 );
469 patched = patched.replace(
470 ", Some(user.tenant_id))",
471 ")",
472 );
473 let lines: Vec<&str> = patched.lines().collect();
475 let mut result = Vec::new();
476 let mut skip_block = false;
477 for line in &lines {
478 if line.contains("// Resolve tenant:") {
479 skip_block = true;
480 continue;
481 }
482 if skip_block {
483 if line.contains("let model = user::ActiveModel") || line.contains("let model: user::ActiveModel") {
485 skip_block = false;
486 } else {
488 continue;
489 }
490 }
491 if line.contains("tenant_id: Set(tenant_id)") {
493 continue;
494 }
495 let trimmed = line.trim();
497 if trimmed.starts_with("tenant_id:") && trimmed.ends_with(".tenant_id,") {
498 continue;
499 }
500 result.push(*line);
501 }
502 patched = result.join("\n");
503 if !patched.ends_with('\n') {
504 patched.push('\n');
505 }
506
507 std::fs::write(&auth_handlers_path, patched)?;
508 println!(
509 " {} backend/src/handlers/auth.rs (removed tenant_id)",
510 "patch".yellow()
511 );
512 }
513 }
514
515 cleanup_entity_tenant_references(project_root)?;
517
518 super::remove_feature_flag(project_root, "multitenancy")?;
520
521 crate::ai_context::regenerate(project_root).ok();
525
526 println!();
527 println!(
528 "{}",
529 "Multitenancy uninstalled successfully.".green().bold()
530 );
531 println!(
532 " {}",
533 "Note: Migration files were left in place. Create a new migration to remove tenant_id columns."
534 .dimmed()
535 );
536
537 Ok(())
538}
539
540fn cleanup_entity_tenant_references(project_root: &Path) -> Result<()> {
543 use colored::Colorize;
544
545 let handlers_dir = project_root.join("backend/src/handlers");
547 if handlers_dir.exists() {
548 for entry in std::fs::read_dir(&handlers_dir)? {
549 let entry = entry?;
550 let path = entry.path();
551 if path.extension().map(|e| e == "rs").unwrap_or(false) {
552 let name = path.file_stem().unwrap().to_string_lossy().to_string();
553 if matches!(
555 name.as_str(),
556 "auth" | "tenant" | "mod" | "upload" | "search" | "audit_log" | "dev_dashboard"
557 ) {
558 continue;
559 }
560 let content = std::fs::read_to_string(&path)?;
561 if !content.contains("TenantGuard") {
562 continue;
563 }
564 let mut patched = content;
565 patched = patched.replace(
567 "use crate::tenant::TenantGuard;\n",
568 "use crate::auth::AuthUser;\n",
569 );
570 patched = patched.replace("tenant: TenantGuard,", "_auth: AuthUser,");
572 patched = patched.replace("tenant.claims.", "_auth.0.");
574
575 let lines: Vec<&str> = patched.lines().collect();
577 let mut result = Vec::new();
578 for line in &lines {
579 let trimmed = line.trim();
580 if trimmed.contains("Column::TenantId.eq(tenant.tenant_id)") {
581 continue;
582 }
583 if trimmed.contains("tenant_id: Set(tenant.tenant_id)") {
584 continue;
585 }
586 result.push(*line);
587 }
588 patched = result.join("\n");
589 if !patched.ends_with('\n') {
590 patched.push('\n');
591 }
592 std::fs::write(&path, patched)?;
593 println!(
594 " {} backend/src/handlers/{}.rs (removed tenant_id)",
595 "patch".yellow(),
596 name
597 );
598 }
599 }
600 }
601
602 let entities_dir = project_root.join("backend/src/entities");
604 if entities_dir.exists() {
605 for entry in std::fs::read_dir(&entities_dir)? {
606 let entry = entry?;
607 let path = entry.path();
608 if path.extension().map(|e| e == "rs").unwrap_or(false) {
609 let name = path.file_stem().unwrap().to_string_lossy().to_string();
610 if matches!(name.as_str(), "user" | "tenant" | "mod" | "audit_entry") {
611 continue;
612 }
613 let content = std::fs::read_to_string(&path)?;
614 if !content.contains("tenant_id") && !content.contains("super::tenant") {
615 continue;
616 }
617
618 let lines: Vec<&str> = content.lines().collect();
619 let mut result: Vec<&str> = Vec::new();
620 let mut skip_relation_attr = false;
621 let mut skip_related_impl = false;
622 let mut impl_depth: i32 = 0;
623
624 for line in &lines {
625 let trimmed = line.trim();
626
627 if trimmed == "pub tenant_id: Uuid," {
629 continue;
630 }
631
632 if trimmed.contains("belongs_to = \"super::tenant::Entity\"") {
634 skip_relation_attr = true;
635 if let Some(last) = result.last() {
637 if last.trim() == "#[sea_orm(" {
638 result.pop();
639 }
640 }
641 continue;
642 }
643 if skip_relation_attr {
644 if trimmed == "Tenant," {
645 skip_relation_attr = false;
646 }
647 continue;
648 }
649
650 if trimmed.starts_with("impl Related<super::tenant::Entity>") {
652 skip_related_impl = true;
653 impl_depth = 0;
654 for ch in trimmed.chars() {
655 if ch == '{' { impl_depth += 1; }
656 if ch == '}' { impl_depth -= 1; }
657 }
658 continue;
659 }
660 if skip_related_impl {
661 for ch in trimmed.chars() {
662 if ch == '{' { impl_depth += 1; }
663 if ch == '}' { impl_depth -= 1; }
664 }
665 if impl_depth <= 0 {
666 skip_related_impl = false;
667 }
668 continue;
669 }
670
671 result.push(*line);
672 }
673
674 let mut patched = result.join("\n");
675 if !patched.ends_with('\n') {
676 patched.push('\n');
677 }
678 std::fs::write(&path, patched)?;
679 println!(
680 " {} backend/src/entities/{}.rs (removed tenant_id)",
681 "patch".yellow(),
682 name
683 );
684 }
685 }
686 }
687
688 Ok(())
689}