From 80d41c9487b3304b69e0547182ea17d6c6d0eb27 Mon Sep 17 00:00:00 2001 From: icamarillo Date: Tue, 17 Mar 2026 13:49:23 -0600 Subject: [PATCH] Permisos en progreso --- backend/app/api/v1/endpoints/tenants.py | 20 + backend/app/models/__init__.py | 2 + backend/app/models/permission.py | 67 +++ .../0aec0a6e294a_add_tenant_permissions.py | 282 +++++++++++ .../src/lib/components/IssueModal.svelte | 146 ++++++ frontend-client/src/lib/stores/auth.ts | 45 +- frontend-client/src/lib/stores/tickets.ts | 219 +++++---- .../src/routes/tickets/[id]/+page.svelte | 451 ++++++------------ .../src/routes/tickets/[id]/+page.svelte | 188 ++++---- 9 files changed, 913 insertions(+), 507 deletions(-) create mode 100644 backend/app/models/permission.py create mode 100644 backend/migrations/versions/0aec0a6e294a_add_tenant_permissions.py create mode 100644 frontend-client/src/lib/components/IssueModal.svelte diff --git a/backend/app/api/v1/endpoints/tenants.py b/backend/app/api/v1/endpoints/tenants.py index b9d001a..6d33831 100644 --- a/backend/app/api/v1/endpoints/tenants.py +++ b/backend/app/api/v1/endpoints/tenants.py @@ -28,6 +28,9 @@ async def create_tenant( db: AsyncSession = Depends(get_db), current_user = Depends(deps.get_current_active_superuser) ): + from app.models.permission import TenantPermission, DEFAULT_PERMISSIONS + from datetime import datetime + # Check existing slug query = select(Tenant).where(Tenant.slug == tenant.slug) result = await db.execute(query) @@ -38,6 +41,23 @@ async def create_tenant( data['slug'] = data['slug'].lower().strip() db_tenant = Tenant(**data) db.add(db_tenant) + await db.flush() # genera el id sin hacer commit + + # Inicializar permisos por defecto para el nuevo tenant + now = datetime.utcnow() + for role, perms in DEFAULT_PERMISSIONS.items(): + for permission, granted in perms.items(): + db.add(TenantPermission( + id=uuid.uuid4(), + tenant_id=db_tenant.id, + user_id=None, + role=role, + permission=permission, + granted=granted, + created_at=now, + updated_at=now, + )) + await db.commit() await db.refresh(db_tenant) return db_tenant diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 02418a4..9b26971 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ from .client_profile import ClientProfile from .attachment import TicketAttachment from .audit import AuditLog from .refresh_token import RefreshToken +from .permission import TenantPermission __all__ = [ "User", @@ -18,6 +19,7 @@ __all__ = [ "Ticket", "TicketIssue", "TicketComment", + "TenantPermission", "System", "Category", "ClientProfile", diff --git a/backend/app/models/permission.py b/backend/app/models/permission.py new file mode 100644 index 0000000..e242709 --- /dev/null +++ b/backend/app/models/permission.py @@ -0,0 +1,67 @@ +""" +TenantPermission Model - ServiceManagerWeb + +Permisos dinámicos por tenant. +- Si user_id es None → aplica al rol completo (default del tenant) +- Si user_id tiene valor → override individual para ese usuario +""" +from sqlalchemy import String, Boolean, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import Optional +import uuid + +from app.core.database import Base, GUID + + +CLIENT_PERMISSIONS = [ + "view_tickets", + "create_tickets", + "close_tickets", + "view_reports", + "manage_tenant_users", + "create_issues", +] + +DEFAULT_PERMISSIONS = { + "CLIENT_ADMIN": {p: True for p in CLIENT_PERMISSIONS}, + "CLIENT_USER": { + "view_tickets": True, + "create_tickets": True, + "close_tickets": False, + "view_reports": False, + "manage_tenant_users": False, + "create_issues": False, + } +} + + +class TenantPermission(Base): + __tablename__ = "tenant_permissions" + + tenant_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + GUID(), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, + ) + role: Mapped[str] = mapped_column(String(50), nullable=False) + permission: Mapped[str] = mapped_column(String(100), nullable=False) + granted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + tenant: Mapped["Tenant"] = relationship("Tenant") + user: Mapped[Optional["User"]] = relationship("User") + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "role", "user_id", "permission", + name="uq_tenant_permission" + ), + ) + + def __repr__(self) -> str: + scope = f"user:{self.user_id}" if self.user_id else f"role:{self.role}" + return f"" \ No newline at end of file diff --git a/backend/migrations/versions/0aec0a6e294a_add_tenant_permissions.py b/backend/migrations/versions/0aec0a6e294a_add_tenant_permissions.py new file mode 100644 index 0000000..abe3706 --- /dev/null +++ b/backend/migrations/versions/0aec0a6e294a_add_tenant_permissions.py @@ -0,0 +1,282 @@ +"""add_tenant_permissions + +Revision ID: 0aec0a6e294a +Revises: b2dcb926e091 +Create Date: 2026-03-17 19:33:30.229993 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '0aec0a6e294a' +down_revision = 'b2dcb926e091' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('idx_email_templates_tenant_id', table_name='email_templates') + op.drop_table('email_templates') + op.drop_index('idx_ticket_status_history_changed_by', table_name='ticket_status_history') + op.drop_index('idx_ticket_status_history_created_at', table_name='ticket_status_history') + op.drop_index('idx_ticket_status_history_ticket_id', table_name='ticket_status_history') + op.drop_table('ticket_status_history') + op.drop_index('idx_notification_logs_created_at', table_name='notification_logs') + op.drop_index('idx_notification_logs_recipient', table_name='notification_logs') + op.drop_index('idx_notification_logs_status', table_name='notification_logs') + op.drop_index('idx_notification_logs_tenant_id', table_name='notification_logs') + op.drop_index('idx_notification_logs_ticket_id', table_name='notification_logs') + op.drop_table('notification_logs') + op.add_column('audit_logs', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False)) + op.alter_column('audit_logs', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.drop_index('idx_audit_logs_action', table_name='audit_logs') + op.drop_index('idx_audit_logs_correlation_id', table_name='audit_logs') + op.drop_index('idx_audit_logs_created_at', table_name='audit_logs') + op.drop_index('idx_audit_logs_tenant_id', table_name='audit_logs') + op.drop_index('idx_audit_logs_user_id', table_name='audit_logs') + op.create_index('idx_audit_logs_tenant_action', 'audit_logs', ['tenant_id', 'action'], unique=False) + op.create_index('idx_audit_logs_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False) + op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False) + op.create_index(op.f('ix_audit_logs_correlation_id'), 'audit_logs', ['correlation_id'], unique=False) + op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False) + op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False) + op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False) + op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False) + op.drop_constraint('audit_logs_user_id_fkey', 'audit_logs', type_='foreignkey') + op.drop_constraint('audit_logs_tenant_id_fkey', 'audit_logs', type_='foreignkey') + op.create_foreign_key(None, 'audit_logs', 'users', ['user_id'], ['id'], ondelete='SET NULL') + op.create_foreign_key(None, 'audit_logs', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE') + op.drop_table_comment( + 'audit_logs', + existing_comment='Bit??cora de acciones para auditor??a y compliance', + schema=None + ) + op.drop_index('idx_refresh_tokens_expires', table_name='refresh_tokens') + op.drop_index('idx_refresh_tokens_user_id', table_name='refresh_tokens') + op.create_index('idx_refresh_tokens_user_expires', 'refresh_tokens', ['user_id', 'expires_at'], unique=False) + op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) + op.drop_index('idx_tenants_domain', table_name='tenants') + op.drop_index('idx_tenants_slug', table_name='tenants') + op.drop_index('idx_tenants_status', table_name='tenants') + op.drop_table_comment( + 'tenants', + existing_comment='Organizaciones cliente en el sistema multi-tenant', + schema=None + ) + op.drop_index('idx_ticket_attachments_comment_id', table_name='ticket_attachments') + op.drop_index('idx_ticket_attachments_ticket_id', table_name='ticket_attachments') + op.drop_index('idx_ticket_attachments_uploaded_by', table_name='ticket_attachments') + op.drop_table_comment( + 'ticket_attachments', + existing_comment='Archivos adjuntos en tickets', + schema=None + ) + op.drop_index('idx_ticket_comments_author_id', table_name='ticket_comments') + op.drop_index('idx_ticket_comments_created_at', table_name='ticket_comments') + op.drop_index('idx_ticket_comments_ticket_id', table_name='ticket_comments') + op.drop_table_comment( + 'ticket_comments', + existing_comment='Comentarios en tickets', + schema=None + ) + op.drop_index('idx_tickets_assigned_to', table_name='tickets', postgresql_where='(assigned_to IS NOT NULL)') + op.drop_index('idx_tickets_category', table_name='tickets') + op.drop_index('idx_tickets_created_at', table_name='tickets') + op.drop_index('idx_tickets_created_by', table_name='tickets') + op.drop_index('idx_tickets_number', table_name='tickets') + op.drop_index('idx_tickets_priority', table_name='tickets') + op.drop_index('idx_tickets_sla_resolution', table_name='tickets') + op.drop_index('idx_tickets_sla_response', table_name='tickets') + op.drop_index('idx_tickets_status', table_name='tickets') + op.drop_index('idx_tickets_tenant_id', table_name='tickets') + op.drop_index('idx_tickets_tenant_status', table_name='tickets') + op.drop_table_comment( + 'tickets', + existing_comment='Tickets de soporte - core del negocio', + schema=None + ) + op.drop_index('idx_users_active', table_name='users') + op.drop_index('idx_users_email', table_name='users') + op.drop_index('idx_users_role', table_name='users') + op.drop_index('idx_users_tenant_email', table_name='users') + op.drop_index('idx_users_tenant_id', table_name='users') + op.drop_table_comment( + 'users', + existing_comment='Usuarios del sistema (internos y clientes)', + schema=None + ) + op.drop_column('users', 'backup_codes_temp') + op.drop_column('users', 'password_changed_at') + op.drop_column('users', 'totp_secret_temp') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('totp_secret_temp', sa.VARCHAR(length=32), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('password_changed_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('backup_codes_temp', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True)) + op.create_table_comment( + 'users', + 'Usuarios del sistema (internos y clientes)', + existing_comment=None, + schema=None + ) + op.create_index('idx_users_tenant_id', 'users', ['tenant_id'], unique=False) + op.create_index('idx_users_tenant_email', 'users', ['tenant_id', 'email'], unique=False) + op.create_index('idx_users_role', 'users', ['role'], unique=False) + op.create_index('idx_users_email', 'users', ['email'], unique=False) + op.create_index('idx_users_active', 'users', ['is_active'], unique=False) + op.create_table_comment( + 'tickets', + 'Tickets de soporte - core del negocio', + existing_comment=None, + schema=None + ) + op.create_index('idx_tickets_tenant_status', 'tickets', ['tenant_id', 'status'], unique=False) + op.create_index('idx_tickets_tenant_id', 'tickets', ['tenant_id'], unique=False) + op.create_index('idx_tickets_status', 'tickets', ['status'], unique=False) + op.create_index('idx_tickets_sla_response', 'tickets', ['sla_response_due'], unique=False) + op.create_index('idx_tickets_sla_resolution', 'tickets', ['sla_resolution_due'], unique=False) + op.create_index('idx_tickets_priority', 'tickets', ['priority'], unique=False) + op.create_index('idx_tickets_number', 'tickets', ['ticket_number'], unique=False) + op.create_index('idx_tickets_created_by', 'tickets', ['created_by'], unique=False) + op.create_index('idx_tickets_created_at', 'tickets', ['created_at'], unique=False) + op.create_index('idx_tickets_category', 'tickets', ['category_id'], unique=False) + op.create_index('idx_tickets_assigned_to', 'tickets', ['assigned_to'], unique=False, postgresql_where='(assigned_to IS NOT NULL)') + op.create_table_comment( + 'ticket_comments', + 'Comentarios en tickets', + existing_comment=None, + schema=None + ) + op.create_index('idx_ticket_comments_ticket_id', 'ticket_comments', ['ticket_id'], unique=False) + op.create_index('idx_ticket_comments_created_at', 'ticket_comments', ['created_at'], unique=False) + op.create_index('idx_ticket_comments_author_id', 'ticket_comments', ['author_id'], unique=False) + op.create_table_comment( + 'ticket_attachments', + 'Archivos adjuntos en tickets', + existing_comment=None, + schema=None + ) + op.create_index('idx_ticket_attachments_uploaded_by', 'ticket_attachments', ['uploaded_by'], unique=False) + op.create_index('idx_ticket_attachments_ticket_id', 'ticket_attachments', ['ticket_id'], unique=False) + op.create_index('idx_ticket_attachments_comment_id', 'ticket_attachments', ['comment_id'], unique=False) + op.create_table_comment( + 'tenants', + 'Organizaciones cliente en el sistema multi-tenant', + existing_comment=None, + schema=None + ) + op.create_index('idx_tenants_status', 'tenants', ['status'], unique=False) + op.create_index('idx_tenants_slug', 'tenants', ['slug'], unique=False) + op.create_index('idx_tenants_domain', 'tenants', ['domain'], unique=False) + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens') + op.drop_index('idx_refresh_tokens_user_expires', table_name='refresh_tokens') + op.create_index('idx_refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False) + op.create_index('idx_refresh_tokens_expires', 'refresh_tokens', ['expires_at'], unique=False) + op.create_table_comment( + 'audit_logs', + 'Bit??cora de acciones para auditor??a y compliance', + existing_comment=None, + schema=None + ) + op.drop_constraint(None, 'audit_logs', type_='foreignkey') + op.drop_constraint(None, 'audit_logs', type_='foreignkey') + op.create_foreign_key('audit_logs_tenant_id_fkey', 'audit_logs', 'tenants', ['tenant_id'], ['id']) + op.create_foreign_key('audit_logs_user_id_fkey', 'audit_logs', 'users', ['user_id'], ['id']) + op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_correlation_id'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs') + op.drop_index('idx_audit_logs_user_created', table_name='audit_logs') + op.drop_index('idx_audit_logs_tenant_action', table_name='audit_logs') + op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'], unique=False) + op.create_index('idx_audit_logs_tenant_id', 'audit_logs', ['tenant_id'], unique=False) + op.create_index('idx_audit_logs_created_at', 'audit_logs', ['created_at'], unique=False) + op.create_index('idx_audit_logs_correlation_id', 'audit_logs', ['correlation_id'], unique=False) + op.create_index('idx_audit_logs_action', 'audit_logs', ['action'], unique=False) + op.alter_column('audit_logs', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_column('audit_logs', 'updated_at') + op.create_table('notification_logs', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False), + sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('recipient_email', sa.VARCHAR(length=320), autoincrement=False, nullable=False), + sa.Column('subject', sa.VARCHAR(length=500), autoincrement=False, nullable=False), + sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True), + sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('provider', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.Column('external_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True), + sa.CheckConstraint("status::text = ANY (ARRAY['pending'::character varying, 'sent'::character varying, 'failed'::character varying, 'bounced'::character varying]::text[])", name='notification_logs_status_check'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='notification_logs_tenant_id_fkey'), + sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='notification_logs_ticket_id_fkey'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='notification_logs_user_id_fkey'), + sa.PrimaryKeyConstraint('id', name='notification_logs_pkey') + ) + op.create_index('idx_notification_logs_ticket_id', 'notification_logs', ['ticket_id'], unique=False) + op.create_index('idx_notification_logs_tenant_id', 'notification_logs', ['tenant_id'], unique=False) + op.create_index('idx_notification_logs_status', 'notification_logs', ['status'], unique=False) + op.create_index('idx_notification_logs_recipient', 'notification_logs', ['recipient_email'], unique=False) + op.create_index('idx_notification_logs_created_at', 'notification_logs', ['created_at'], unique=False) + op.create_table('ticket_status_history', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False), + sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('changed_by', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('old_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=True), + sa.Column('new_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=False), + sa.Column('old_assigned_to', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('new_assigned_to', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('comment', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['changed_by'], ['users.id'], name='ticket_status_history_changed_by_fkey'), + sa.ForeignKeyConstraint(['new_assigned_to'], ['users.id'], name='ticket_status_history_new_assigned_to_fkey'), + sa.ForeignKeyConstraint(['old_assigned_to'], ['users.id'], name='ticket_status_history_old_assigned_to_fkey'), + sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_status_history_ticket_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='ticket_status_history_pkey') + ) + op.create_index('idx_ticket_status_history_ticket_id', 'ticket_status_history', ['ticket_id'], unique=False) + op.create_index('idx_ticket_status_history_created_at', 'ticket_status_history', ['created_at'], unique=False) + op.create_index('idx_ticket_status_history_changed_by', 'ticket_status_history', ['changed_by'], unique=False) + op.create_table('email_templates', + sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('template_key', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('subject_template', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('html_template', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('text_template', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('is_system', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('required_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True), + sa.Column('default_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True), + sa.Column('from_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('from_email', sa.VARCHAR(length=320), autoincrement=False, nullable=True), + sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('last_used_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('usage_count', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='email_templates_tenant_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='email_templates_pkey') + ) + op.create_index('idx_email_templates_tenant_id', 'email_templates', ['tenant_id'], unique=False) + # ### end Alembic commands ### \ No newline at end of file diff --git a/frontend-client/src/lib/components/IssueModal.svelte b/frontend-client/src/lib/components/IssueModal.svelte new file mode 100644 index 0000000..7f67832 --- /dev/null +++ b/frontend-client/src/lib/components/IssueModal.svelte @@ -0,0 +1,146 @@ + + +
+
+
e.key === 'Escape' && handleClose()} + /> + +
+
+

Crear Asunto

+ +
+ +
+
+ +