diff --git a/README-Database.md b/README-Database.md new file mode 100644 index 0000000..f0b1ed5 --- /dev/null +++ b/README-Database.md @@ -0,0 +1,441 @@ +# Database Implementation + +This document describes the PostgreSQL database implementation for the Software Release Management Platform. + +## Architecture Overview + +The database layer uses **Entity Framework Core 8** with **PostgreSQL 15** and includes: + +- **Soft Delete Pattern**: All entities support soft deletes with audit trail +- **Repository Pattern**: Clean abstraction layer for data access +- **Database Constraints**: Enforced data integrity at the database level +- **JSON Support**: PostgreSQL JSONB columns for flexible configuration storage +- **Migration Support**: Code-first database schema management +- **Connection Pooling**: Optimized for high-performance applications + +## Entity Relationships + +```mermaid +erDiagram + Users { + int Id PK + string Username UK + string PasswordHash + string Role + datetime CreatedAt + datetime LastLogin + bool IsDeleted + } + + Projects { + int Id PK + string Name + string CCNetProjectName UK + string Status + datetime CreatedAt + bool IsDeleted + } + + Builds { + int Id PK + int ProjectId FK + string BuildNumber + string Status + datetime StartTime + datetime EndTime + bool IsDeleted + } + + Packages { + int Id PK + string Title + string Version + int ProjectId FK + int SourceBuildId FK + string Status + datetime PublishDate + bool IsDeleted + } + + PackageConfigurations { + int Id PK + int PackageId FK + string BuildFolder + bool ZipContents + jsonb StorageSettings + jsonb HelpCenterSettings + bool IsDeleted + } + + Publications { + int Id PK + int PackageId FK + string Status + datetime PublishedAt + jsonb PublicationDetails + bool IsDeleted + } + + PublishingSteps { + int Id PK + int PublicationId FK + string StepName + string Status + int ProgressPercent + datetime StartedAt + datetime CompletedAt + bool IsDeleted + } + + FogBugzCases { + int Id PK + int CaseId UK + string Title + string Status + datetime LastUpdated + bool IsOpen + bool IsDeleted + } + + FogBugzEvents { + int Id PK + int CaseId FK + string EventType + string User + datetime EventDate + string ReleaseNote + int ZendeskNumber + bool IsDeleted + } + + BuildCommits { + int Id PK + int BuildId FK + string CommitHash + string FogBugzCaseId + jsonb ModifiedFiles + datetime CommitDate + bool IsDeleted + } + + StorageProviders { + int Id PK + string Name UK + string Type + jsonb Configuration + bool IsActive + bool IsDeleted + } + + HelpCenterProviders { + int Id PK + string Name UK + string Type + jsonb Configuration + bool IsActive + bool IsDeleted + } + + Projects ||--o{ Builds : "produces" + Projects ||--o{ Packages : "contains" + Builds ||--o{ BuildCommits : "includes" + Packages ||--o{ PackageConfigurations : "configured by" + Packages ||--o{ Publications : "published as" + Publications ||--o{ PublishingSteps : "tracked by" + FogBugzCases ||--o{ FogBugzEvents : "has history" + BuildCommits }o--|| FogBugzCases : "references" +``` + +## Database Schema + +### Core Tables + +#### Users +- **Purpose**: User authentication and authorization +- **Key Features**: Role-based access control, password hashing, audit trail + +#### Projects +- **Purpose**: CruiseControl.NET project integration +- **Key Features**: Maps to CC.NET project names, tracks build history + +#### Builds +- **Purpose**: Individual build records from CC.NET +- **Key Features**: Build status tracking, timing information, artifact paths + +#### BuildCommits +- **Purpose**: Source code commits within builds +- **Key Features**: FogBugz case linking, modified files (JSON), commit metadata + +### Package Management + +#### Packages +- **Purpose**: User-defined software release configurations +- **Key Features**: Version tracking, publishing status, project association + +#### PackageConfigurations +- **Purpose**: Package-specific publishing settings +- **Key Features**: Storage settings (JSON), help center settings (JSON), build options + +### Publishing Workflow + +#### Publications +- **Purpose**: Publishing execution records +- **Key Features**: Workflow status, publication details (JSON), timing + +#### PublishingSteps +- **Purpose**: Individual workflow step tracking +- **Key Features**: Progress percentage, error handling, step ordering + +### External Integration + +#### FogBugzCases & FogBugzEvents +- **Purpose**: FogBugz/Manuscript integration +- **Key Features**: Case history, release note extraction, Zendesk linking + +#### StorageProviders & HelpCenterProviders +- **Purpose**: External service configuration +- **Key Features**: Pluggable providers, encrypted configuration (JSON) + +## Soft Delete Implementation + +All entities inherit from `BaseEntity` with soft delete support: + +```csharp +public abstract class BaseEntity +{ + public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } + public string? DeletedBy { get; set; } + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } +} +``` + +### Query Filters +Entity Framework automatically filters out soft-deleted records: +```csharp +modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); +``` + +### Manual Override +To include soft-deleted records: +```csharp +var allUsers = context.Users.IgnoreQueryFilters().Where(u => u.IsDeleted); +``` + +## Repository Pattern + +### Interface Example +```csharp +public interface IUserRepository +{ + Task GetByIdAsync(int id); + Task GetByUsernameAsync(string username); + Task> GetAllAsync(); + Task CreateAsync(User user); + Task UpdateAsync(User user); + Task DeleteAsync(int id, string deletedBy); + Task ExistsAsync(int id); +} +``` + +### Benefits +- Clean separation of concerns +- Testable data access layer +- Consistent error handling +- Transaction management + +## Database Configuration + +### Connection String +Production connection strings stored in `secrets.yaml`: +```yaml +Database: + ConnectionString: "Host=localhost;Port=5432;Database=software_release_management;Username=postgres;Password=your_secure_password" +``` + +### Performance Settings +```json +{ + "Database": { + "CommandTimeout": 30, + "Pooling": { + "MinPoolSize": 5, + "MaxPoolSize": 100, + "ConnectionIdleLifetime": 300 + }, + "Retry": { + "EnableRetryOnFailure": true, + "MaxRetryCount": 5, + "MaxRetryDelay": 30 + } + } +} +``` + +## Setup Instructions + +### 1. Prerequisites +- Docker and Docker Compose +- .NET 8 SDK +- PostgreSQL client tools (optional) + +### 2. Start Database +```bash +# Start PostgreSQL with Docker Compose +docker compose up postgres -d + +# Optional: Start with PgAdmin +docker compose --profile dev up -d +``` + +### 3. Run Migrations +```bash +cd src/Database +dotnet ef migrations add InitialCreate +dotnet ef database update +``` + +### 4. Seed Data +```bash +# Development environment automatically seeds test data +ASPNETCORE_ENVIRONMENT=Development dotnet run +``` + +## Development Workflow + +### Creating Migrations +```bash +# Navigate to Database project +cd src/Database + +# Create new migration +dotnet ef migrations add MigrationName + +# Review generated migration files +# Apply migration +dotnet ef database update +``` + +### Database Scripts +```bash +# Generate SQL script for production deployment +dotnet ef migrations script --output migrations.sql + +# Generate script for specific migration range +dotnet ef migrations script PreviousMigration TargetMigration +``` + +### Data Seeding +The database automatically seeds: +- Default users (admin, user, readonly) +- Storage providers (AWS S3, Box.com, FTP) +- Help center providers (Zendesk, Salesforce) +- Test projects and packages (development only) + +## Security Features + +### Password Hashing +- BCrypt with work factor 12 +- Automatic salt generation +- Secure password verification + +### Secrets Management +- Production secrets in `secrets.yaml` +- Environment variable overrides +- Docker secrets support + +### Database Security +- Connection pooling limits +- SQL injection protection (parameterized queries) +- Role-based access control +- Audit logging + +## Performance Considerations + +### Indexing Strategy +- Primary keys (clustered indexes) +- Foreign key relationships +- Query optimization indexes: + - `Users.Username` (unique) + - `Projects.CCNetProjectName` (unique) + - `Builds.StartTime`, `Builds.Status` + - `FogBugzCases.LastUpdated` + - `Publications.Status`, `Publications.PublishedAt` + +### JSON Column Usage +PostgreSQL JSONB columns provide: +- Efficient storage and querying +- Flexible configuration schemas +- GIN index support for complex queries + +### Connection Pooling +- Configured connection limits +- Idle connection cleanup +- Health check monitoring + +## Monitoring and Maintenance + +### Health Checks +```csharp +services.AddHealthChecks() + .AddDbContextCheck("database"); +``` + +### Logging +- Entity Framework query logging +- Migration execution logs +- Performance monitoring +- Error tracking + +### Backup Strategy +```bash +# Database backup +pg_dump -h localhost -p 5432 -U postgres -d software_release_management > backup.sql + +# Restore from backup +psql -h localhost -p 5432 -U postgres -d software_release_management < backup.sql +``` + +## Troubleshooting + +### Common Issues + +1. **Connection Issues** + ```bash + # Check PostgreSQL status + docker compose logs postgres + + # Test connection + psql -h localhost -p 5432 -U postgres -d software_release_management + ``` + +2. **Migration Failures** + ```bash + # Reset migrations (development only) + dotnet ef database drop --force + dotnet ef database update + ``` + +3. **Performance Issues** + ```sql + -- Check slow queries + SELECT query, mean_time, calls + FROM pg_stat_statements + ORDER BY mean_time DESC LIMIT 10; + ``` + +### Support +- Check Entity Framework documentation +- Review PostgreSQL logs +- Monitor application performance +- Use PgAdmin for database administration + +## Next Steps +- Set up monitoring and alerting +- Implement database backup automation +- Configure read replicas for scaling +- Set up connection string encryption \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..78937ad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: srm_postgres + environment: + POSTGRES_DB: software_release_management + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data/pgdata + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init-db.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d software_release_management"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - srm-network + + # Database migration service + database-migrator: + build: + context: ./src/Database + dockerfile: Dockerfile.migrator + container_name: srm_db_migrator + depends_on: + postgres: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=software_release_management;Username=postgres;Password=postgres + networks: + - srm-network + restart: no + + # PgAdmin for database management (optional) + pgadmin: + image: dpage/pgadmin4:latest + container_name: srm_pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: admin@company.com + PGADMIN_DEFAULT_PASSWORD: admin123 + PGADMIN_CONFIG_SERVER_MODE: 'False' + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' + ports: + - "5050:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + - ./scripts/servers.json:/pgadmin4/servers.json:ro + depends_on: + - postgres + networks: + - srm-network + profiles: + - dev + restart: unless-stopped + +volumes: + postgres_data: + driver: local + pgadmin_data: + driver: local + +networks: + srm-network: + driver: bridge + name: software-release-management \ No newline at end of file diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..f978f9d --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,30 @@ +-- Initialize PostgreSQL database for Software Release Management +-- This script runs during container initialization + +-- Create additional users if needed +-- CREATE USER srm_app WITH PASSWORD 'secure_password'; +-- GRANT CONNECT ON DATABASE software_release_management TO srm_app; + +-- Enable UUID extension if needed in the future +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Enable PostGIS extension if geographical features are needed +-- CREATE EXTENSION IF NOT EXISTS postgis; + +-- Set timezone +SET timezone = 'UTC'; + +-- Create schema for application (optional, using public by default) +-- CREATE SCHEMA IF NOT EXISTS srm; + +-- Grant permissions +-- GRANT USAGE ON SCHEMA public TO srm_app; +-- GRANT CREATE ON SCHEMA public TO srm_app; + +-- Log initialization +DO $$ +BEGIN + RAISE NOTICE 'Database initialized for Software Release Management'; + RAISE NOTICE 'Version: PostgreSQL %', version(); + RAISE NOTICE 'Current time: %', NOW(); +END $$; \ No newline at end of file diff --git a/scripts/servers.json b/scripts/servers.json new file mode 100644 index 0000000..7f76693 --- /dev/null +++ b/scripts/servers.json @@ -0,0 +1,23 @@ +{ + "Servers": { + "1": { + "Name": "Software Release Management", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "postgres", + "Username": "postgres", + "PassFile": "/tmp/pgpassfile", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelHost": "", + "TunnelPort": "22", + "TunnelUsername": "", + "TunnelAuthentication": 0 + } + } +} \ No newline at end of file diff --git a/secrets.yaml b/secrets.yaml new file mode 100644 index 0000000..cd7ecfc --- /dev/null +++ b/secrets.yaml @@ -0,0 +1,59 @@ +# Database Secrets Configuration +# This file contains sensitive configuration data and should be secured +# Use environment variables or secure key vaults in production + +Database: + ConnectionString: "Host=localhost;Port=5432;Database=software_release_management;Username=postgres;Password=your_secure_password" + +# External Service Credentials +StorageProviders: + AWS: + AccessKey: "YOUR_AWS_ACCESS_KEY" + SecretKey: "YOUR_AWS_SECRET_KEY" + + Box: + ClientId: "YOUR_BOX_CLIENT_ID" + ClientSecret: "YOUR_BOX_CLIENT_SECRET" + AccessToken: "YOUR_BOX_ACCESS_TOKEN" + RefreshToken: "YOUR_BOX_REFRESH_TOKEN" + + FTP: + Username: "YOUR_FTP_USERNAME" + Password: "YOUR_FTP_PASSWORD" + +HelpCenterProviders: + Zendesk: + Username: "YOUR_ZENDESK_USERNAME" + ApiKey: "YOUR_ZENDESK_API_KEY" + + Salesforce: + Username: "YOUR_SALESFORCE_USERNAME" + Password: "YOUR_SALESFORCE_PASSWORD" + ClientId: "YOUR_SALESFORCE_CLIENT_ID" + ClientSecret: "YOUR_SALESFORCE_CLIENT_SECRET" + SecurityToken: "YOUR_SALESFORCE_SECURITY_TOKEN" + +# External Integration Services +ExternalServices: + CruiseControl: + ServerAddress: "ccnet.company.local" + Port: 21234 + + FogBugz: + ServerAddress: "fogbugz.company.local" + ApiEndpoint: "/api.asp" + AuthToken: "YOUR_FOGBUGZ_TOKEN" + +# JWT Authentication +Authentication: + SecretKey: "YOUR_JWT_SECRET_KEY_AT_LEAST_32_CHARACTERS_LONG" + Issuer: "SoftwareReleaseManagement" + Audience: "SRM-API" + ExpirationMinutes: 60 + RefreshTokenExpirationDays: 30 + +# Application Settings +Application: + DefaultAdminUsername: "admin" + DefaultAdminPassword: "ChangeThisPassword123!" + DefaultAdminEmail: "admin@company.com" \ No newline at end of file diff --git a/src/Database/Configuration/DatabaseConfiguration.cs b/src/Database/Configuration/DatabaseConfiguration.cs new file mode 100644 index 0000000..7e2b873 --- /dev/null +++ b/src/Database/Configuration/DatabaseConfiguration.cs @@ -0,0 +1,47 @@ +namespace Database.Configuration +{ + public class DatabaseConfiguration + { + public const string SectionName = "Database"; + + public string ConnectionString { get; set; } = string.Empty; + + public bool EnableSensitiveDataLogging { get; set; } = false; + + public bool EnableDetailedErrors { get; set; } = false; + + public int CommandTimeout { get; set; } = 30; + + public bool AutoMigrate { get; set; } = false; + + public bool SeedTestData { get; set; } = false; + + public string Environment { get; set; } = "Development"; + + public PoolingOptions Pooling { get; set; } = new(); + + public RetryOptions Retry { get; set; } = new(); + } + + public class PoolingOptions + { + public int MinPoolSize { get; set; } = 5; + + public int MaxPoolSize { get; set; } = 100; + + public int ConnectionIdleLifetime { get; set; } = 300; // seconds + + public int ConnectionPruningInterval { get; set; } = 10; // seconds + } + + public class RetryOptions + { + public bool EnableRetryOnFailure { get; set; } = true; + + public int MaxRetryCount { get; set; } = 5; + + public int MaxRetryDelay { get; set; } = 30; // seconds + + public List ErrorNumbersToAdd { get; set; } = new(); + } +} \ No newline at end of file diff --git a/src/Database/Database.csproj b/src/Database/Database.csproj new file mode 100644 index 0000000..a41ef54 --- /dev/null +++ b/src/Database/Database.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + latest + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + \ No newline at end of file diff --git a/src/Database/DatabaseContext.cs b/src/Database/DatabaseContext.cs new file mode 100644 index 0000000..28fad1e --- /dev/null +++ b/src/Database/DatabaseContext.cs @@ -0,0 +1,360 @@ +using Microsoft.EntityFrameworkCore; +using Database.Models; + +namespace Database +{ + public class DatabaseContext : DbContext + { + public DatabaseContext(DbContextOptions options) : base(options) + { + } + + // Core Entities + public DbSet Users { get; set; } + public DbSet Packages { get; set; } + public DbSet PackageConfigurations { get; set; } + public DbSet Projects { get; set; } + public DbSet Builds { get; set; } + public DbSet BuildCommits { get; set; } + + // FogBugz Integration + public DbSet FogBugzCases { get; set; } + public DbSet FogBugzEvents { get; set; } + + // Publishing Workflow + public DbSet Publications { get; set; } + public DbSet PublishingSteps { get; set; } + + // Provider Configurations + public DbSet StorageProviders { get; set; } + public DbSet HelpCenterProviders { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure soft delete filter + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); + + // Configure relationships + ConfigureUserRelationships(modelBuilder); + ConfigurePackageRelationships(modelBuilder); + ConfigureProjectRelationships(modelBuilder); + ConfigureBuildRelationships(modelBuilder); + ConfigureFogBugzRelationships(modelBuilder); + ConfigurePublishingRelationships(modelBuilder); + ConfigureProviderRelationships(modelBuilder); + + // Configure indexes + ConfigureIndexes(modelBuilder); + + // Configure database constraints + ConfigureConstraints(modelBuilder); + } + + private static void ConfigureUserRelationships(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(u => u.Username) + .IsUnique(); + } + + private static void ConfigurePackageRelationships(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(p => p.Project) + .WithMany(pr => pr.Packages) + .HasForeignKey(p => p.ProjectId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(p => p.SourceBuild) + .WithMany() + .HasForeignKey(p => p.SourceBuildId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(pc => pc.Package) + .WithOne(p => p.Configuration) + .HasForeignKey(pc => pc.PackageId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigureProjectRelationships(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(p => p.CCNetProjectName) + .IsUnique(); + } + + private static void ConfigureBuildRelationships(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(b => b.Project) + .WithMany(p => p.Builds) + .HasForeignKey(b => b.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(b => new { b.ProjectId, b.BuildNumber }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(bc => bc.Build) + .WithMany(b => b.Commits) + .HasForeignKey(bc => bc.BuildId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(bc => bc.FogBugzCase) + .WithMany() + .HasForeignKey(bc => bc.FogBugzCaseId) + .OnDelete(DeleteBehavior.SetNull); + } + + private static void ConfigureFogBugzRelationships(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(c => c.CaseId) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(e => e.Case) + .WithMany(c => c.Events) + .HasForeignKey(e => e.CaseId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigurePublishingRelationships(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(pub => pub.Package) + .WithMany(p => p.Publications) + .HasForeignKey(pub => pub.PackageId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(ps => ps.Publication) + .WithMany(p => p.Steps) + .HasForeignKey(ps => ps.PublicationId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigureProviderRelationships(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(sp => sp.Name) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(hcp => hcp.Name) + .IsUnique(); + } + + private static void ConfigureIndexes(ModelBuilder modelBuilder) + { + // Performance indexes + modelBuilder.Entity() + .HasIndex(b => b.Status); + + modelBuilder.Entity() + .HasIndex(b => b.StartTime); + + modelBuilder.Entity() + .HasIndex(bc => bc.FogBugzCaseId); + + modelBuilder.Entity() + .HasIndex(bc => bc.CommitDate); + + modelBuilder.Entity() + .HasIndex(c => c.Status); + + modelBuilder.Entity() + .HasIndex(c => c.LastUpdated); + + modelBuilder.Entity() + .HasIndex(e => e.EventDate); + + modelBuilder.Entity() + .HasIndex(p => p.Status); + + modelBuilder.Entity() + .HasIndex(p => p.PublishedAt); + + modelBuilder.Entity() + .HasIndex(ps => ps.Status); + } + + private static void ConfigureConstraints(ModelBuilder modelBuilder) + { + // String length constraints + modelBuilder.Entity() + .Property(u => u.Username) + .HasMaxLength(100) + .IsRequired(); + + modelBuilder.Entity() + .Property(u => u.Role) + .HasMaxLength(50) + .IsRequired(); + + modelBuilder.Entity() + .Property(p => p.Title) + .HasMaxLength(200) + .IsRequired(); + + modelBuilder.Entity() + .Property(p => p.Version) + .HasMaxLength(50) + .IsRequired(); + + modelBuilder.Entity() + .Property(p => p.Status) + .HasMaxLength(50) + .IsRequired(); + + modelBuilder.Entity() + .Property(p => p.Name) + .HasMaxLength(200) + .IsRequired(); + + modelBuilder.Entity() + .Property(p => p.CCNetProjectName) + .HasMaxLength(200) + .IsRequired(); + + modelBuilder.Entity() + .Property(b => b.BuildNumber) + .HasMaxLength(100) + .IsRequired(); + + modelBuilder.Entity() + .Property(b => b.Status) + .HasMaxLength(50) + .IsRequired(); + + modelBuilder.Entity() + .Property(bc => bc.CommitHash) + .HasMaxLength(40); + + modelBuilder.Entity() + .Property(bc => bc.User) + .HasMaxLength(100); + + modelBuilder.Entity() + .Property(c => c.Title) + .HasMaxLength(500) + .IsRequired(); + + modelBuilder.Entity() + .Property(c => c.Status) + .HasMaxLength(50) + .IsRequired(); + + modelBuilder.Entity() + .Property(e => e.EventType) + .HasMaxLength(100) + .IsRequired(); + + modelBuilder.Entity() + .Property(e => e.User) + .HasMaxLength(100); + + modelBuilder.Entity() + .Property(p => p.Status) + .HasMaxLength(50) + .IsRequired(); + + modelBuilder.Entity() + .Property(ps => ps.StepName) + .HasMaxLength(200) + .IsRequired(); + + modelBuilder.Entity() + .Property(ps => ps.Status) + .HasMaxLength(50) + .IsRequired(); + + modelBuilder.Entity() + .Property(sp => sp.Name) + .HasMaxLength(200) + .IsRequired(); + + modelBuilder.Entity() + .Property(sp => sp.Type) + .HasMaxLength(50) + .IsRequired(); + + modelBuilder.Entity() + .Property(hcp => hcp.Name) + .HasMaxLength(200) + .IsRequired(); + + modelBuilder.Entity() + .Property(hcp => hcp.Type) + .HasMaxLength(50) + .IsRequired(); + + // JSON column configuration + modelBuilder.Entity() + .Property(pc => pc.StorageSettings) + .HasColumnType("jsonb"); + + modelBuilder.Entity() + .Property(pc => pc.HelpCenterSettings) + .HasColumnType("jsonb"); + + modelBuilder.Entity() + .Property(bc => bc.ModifiedFiles) + .HasColumnType("jsonb"); + + modelBuilder.Entity() + .Property(p => p.PublicationDetails) + .HasColumnType("jsonb"); + + modelBuilder.Entity() + .Property(sp => sp.Configuration) + .HasColumnType("jsonb"); + + modelBuilder.Entity() + .Property(hcp => hcp.Configuration) + .HasColumnType("jsonb"); + + // Check constraints + modelBuilder.Entity() + .HasCheckConstraint("CK_PublishingStep_ProgressPercent", + "\"ProgressPercent\" >= 0 AND \"ProgressPercent\" <= 100"); + + modelBuilder.Entity() + .HasCheckConstraint("CK_User_Role", + "\"Role\" IN ('Admin', 'User', 'ReadOnly')"); + + modelBuilder.Entity() + .HasCheckConstraint("CK_Package_Status", + "\"Status\" IN ('Draft', 'Ready', 'Publishing', 'Published', 'Failed')"); + + modelBuilder.Entity() + .HasCheckConstraint("CK_Build_Status", + "\"Status\" IN ('Success', 'Failure', 'Exception', 'Cancelled', 'Unknown')"); + + modelBuilder.Entity() + .HasCheckConstraint("CK_Publication_Status", + "\"Status\" IN ('Pending', 'InProgress', 'Completed', 'Failed', 'Cancelled')"); + + modelBuilder.Entity() + .HasCheckConstraint("CK_PublishingStep_Status", + "\"Status\" IN ('Pending', 'InProgress', 'Completed', 'Failed', 'Skipped')"); + } + } +} \ No newline at end of file diff --git a/src/Database/Extensions/ServiceCollectionExtensions.cs b/src/Database/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..8bbc93e --- /dev/null +++ b/src/Database/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Database.Configuration; +using Database.Services; + +namespace Database.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) + { + // Bind database configuration + var dbConfig = new DatabaseConfiguration(); + configuration.GetSection(DatabaseConfiguration.SectionName).Bind(dbConfig); + services.AddSingleton(dbConfig); + + // Add Entity Framework DbContext + services.AddDbContext(options => + { + options.UseNpgsql(dbConfig.ConnectionString, npgsqlOptions => + { + npgsqlOptions.CommandTimeout(dbConfig.CommandTimeout); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: dbConfig.Retry.MaxRetryCount, + maxRetryDelay: TimeSpan.FromSeconds(dbConfig.Retry.MaxRetryDelay), + errorCodesToAdd: null); + }); + + if (dbConfig.EnableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(); + } + + if (dbConfig.EnableDetailedErrors) + { + options.EnableDetailedErrors(); + } + + // Configure connection pooling + options.UseNpgsql(dbConfig.ConnectionString, npgsqlOptions => + { + npgsqlOptions.CommandTimeout(dbConfig.CommandTimeout); + }); + }); + + // Add database services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddDatabaseRepositories(this IServiceCollection services) + { + // Add repository pattern services if needed + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/Database/Models/BaseEntity.cs b/src/Database/Models/BaseEntity.cs new file mode 100644 index 0000000..df3b0e9 --- /dev/null +++ b/src/Database/Models/BaseEntity.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; + +namespace Database.Models +{ + public abstract class BaseEntity + { + [Key] + public int Id { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public bool IsDeleted { get; set; } = false; + + public DateTime? DeletedAt { get; set; } + + public string? DeletedBy { get; set; } + + public string? CreatedBy { get; set; } + + public string? UpdatedBy { get; set; } + + public void MarkAsDeleted(string deletedBy) + { + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + DeletedBy = deletedBy; + } + + public void UpdateTimestamp(string updatedBy) + { + UpdatedAt = DateTime.UtcNow; + UpdatedBy = updatedBy; + } + } +} \ No newline at end of file diff --git a/src/Database/Models/Build.cs b/src/Database/Models/Build.cs new file mode 100644 index 0000000..ea26db7 --- /dev/null +++ b/src/Database/Models/Build.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Database.Models +{ + public class Build : BaseEntity + { + public int ProjectId { get; set; } + + [Required] + [MaxLength(100)] + public string BuildNumber { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string Status { get; set; } = "Unknown"; // Success, Failure, Exception, Cancelled, Unknown + + public DateTime StartTime { get; set; } + + public DateTime? EndTime { get; set; } + + public string? LogPath { get; set; } + + public string? ArtifactPath { get; set; } + + public TimeSpan? Duration => EndTime?.Subtract(StartTime); + + // Navigation properties + [ForeignKey(nameof(ProjectId))] + public Project Project { get; set; } = null!; + + public ICollection Commits { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/src/Database/Models/BuildCommit.cs b/src/Database/Models/BuildCommit.cs new file mode 100644 index 0000000..98f103f --- /dev/null +++ b/src/Database/Models/BuildCommit.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + +namespace Database.Models +{ + public class BuildCommit : BaseEntity + { + public int BuildId { get; set; } + + [MaxLength(40)] + public string? CommitHash { get; set; } + + public string? Comment { get; set; } + + [MaxLength(100)] + public string? User { get; set; } + + public DateTime CommitDate { get; set; } + + [MaxLength(20)] + public string? FogBugzCaseId { get; set; } + + public int? FogBugzCaseIdInt => int.TryParse(FogBugzCaseId, out int result) ? result : null; + + // JSON column for PostgreSQL + [Column(TypeName = "jsonb")] + public string? ModifiedFiles { get; set; } + + public string? ReleaseNote { get; set; } + + // Navigation properties + [ForeignKey(nameof(BuildId))] + public Build Build { get; set; } = null!; + + [ForeignKey(nameof(FogBugzCaseId))] + public FogBugzCase? FogBugzCase { get; set; } + + // Helper methods for ModifiedFiles JSON + public List GetModifiedFiles() + { + if (string.IsNullOrEmpty(ModifiedFiles)) + return new List(); + + try + { + return JsonSerializer.Deserialize>(ModifiedFiles) ?? new List(); + } + catch + { + return new List(); + } + } + + public void SetModifiedFiles(List files) + { + ModifiedFiles = JsonSerializer.Serialize(files); + } + } +} \ No newline at end of file diff --git a/src/Database/Models/FogBugzCase.cs b/src/Database/Models/FogBugzCase.cs new file mode 100644 index 0000000..cccbe7f --- /dev/null +++ b/src/Database/Models/FogBugzCase.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; + +namespace Database.Models +{ + public class FogBugzCase : BaseEntity + { + [Required] + public int CaseId { get; set; } + + [Required] + [MaxLength(500)] + public string Title { get; set; } = string.Empty; + + [MaxLength(200)] + public string? Project { get; set; } + + [MaxLength(200)] + public string? Area { get; set; } + + [Required] + [MaxLength(50)] + public string Status { get; set; } = "Unknown"; + + public DateTime LastUpdated { get; set; } + + public bool IsOpen { get; set; } = true; + + // Navigation properties + public ICollection Events { get; set; } = new List(); + + // Helper methods + public string GetLatestReleaseNote() + { + return Events + .Where(e => e.EventType == "ReleaseNoted" && !string.IsNullOrEmpty(e.ReleaseNote)) + .OrderByDescending(e => e.EventDate) + .FirstOrDefault()?.ReleaseNote ?? string.Empty; + } + + public int? GetZendeskTicketNumber() + { + return Events + .Where(e => e.ZendeskNumber > 0) + .OrderByDescending(e => e.EventDate) + .FirstOrDefault()?.ZendeskNumber; + } + + public FogBugzStatus GetMappedStatus() + { + return Status.ToLower() switch + { + var s when s.StartsWith("open") => FogBugzStatus.Opened, + var s when s.StartsWith("resolved") => FogBugzStatus.Resolved, + var s when s.StartsWith("closed") => FogBugzStatus.Closed, + var s when s.StartsWith("reactivated") => FogBugzStatus.Reactivated, + _ => FogBugzStatus.Unknown + }; + } + } + + public enum FogBugzStatus + { + Unknown, + Opened, + Resolved, + Closed, + Reactivated + } +} \ No newline at end of file diff --git a/src/Database/Models/FogBugzEvent.cs b/src/Database/Models/FogBugzEvent.cs new file mode 100644 index 0000000..7b7919a --- /dev/null +++ b/src/Database/Models/FogBugzEvent.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Database.Models +{ + public class FogBugzEvent : BaseEntity + { + public int CaseId { get; set; } + + [Required] + [MaxLength(100)] + public string EventType { get; set; } = string.Empty; + + [MaxLength(100)] + public string? User { get; set; } + + public string? Comment { get; set; } + + public string? StatusString { get; set; } + + public DateTime EventDate { get; set; } + + public string? ReleaseNote { get; set; } + + public int? ZendeskNumber { get; set; } + + // Navigation properties + [ForeignKey(nameof(CaseId))] + public FogBugzCase Case { get; set; } = null!; + + // Helper properties + public bool IsReleaseNoteEvent => EventType == "ReleaseNoted" && !string.IsNullOrEmpty(ReleaseNote); + + public bool IsZendeskEvent => EventType == "sZendesk" && ZendeskNumber.HasValue && ZendeskNumber > 0; + + public bool IsStatusChangeEvent => !string.IsNullOrEmpty(StatusString) && + (StatusString.StartsWith("Opened") || StatusString.StartsWith("Resolved") || + StatusString.StartsWith("Closed") || StatusString.StartsWith("Reactivated")); + + public FogBugzStatus GetMappedStatus() + { + if (string.IsNullOrEmpty(StatusString)) + return FogBugzStatus.Unknown; + + return StatusString.ToLower() switch + { + var s when s.StartsWith("opened") => FogBugzStatus.Opened, + var s when s.StartsWith("resolved") => FogBugzStatus.Resolved, + var s when s.StartsWith("closed") => FogBugzStatus.Closed, + var s when s.StartsWith("reactivated") => FogBugzStatus.Reactivated, + _ => FogBugzStatus.Unknown + }; + } + } +} \ No newline at end of file diff --git a/src/Database/Models/HelpCenterProvider.cs b/src/Database/Models/HelpCenterProvider.cs new file mode 100644 index 0000000..8f9cc60 --- /dev/null +++ b/src/Database/Models/HelpCenterProvider.cs @@ -0,0 +1,93 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + +namespace Database.Models +{ + public class HelpCenterProvider : BaseEntity + { + [Required] + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string Type { get; set; } = string.Empty; // Zendesk, Salesforce + + // JSON column for PostgreSQL + [Column(TypeName = "jsonb")] + public string? Configuration { get; set; } + + public bool IsActive { get; set; } = true; + + public string? Description { get; set; } + + public DateTime? LastTested { get; set; } + + public bool? LastTestResult { get; set; } + + public string? LastTestError { get; set; } + + // Helper methods for Configuration JSON + public HelpCenterProviderConfiguration GetConfiguration() + { + if (string.IsNullOrEmpty(Configuration)) + return new HelpCenterProviderConfiguration(); + + try + { + return JsonSerializer.Deserialize(Configuration) ?? new HelpCenterProviderConfiguration(); + } + catch + { + return new HelpCenterProviderConfiguration(); + } + } + + public void SetConfiguration(HelpCenterProviderConfiguration config) + { + Configuration = JsonSerializer.Serialize(config); + } + + // Helper properties + public bool IsZendeskProvider => Type.Equals("Zendesk", StringComparison.OrdinalIgnoreCase); + + public bool IsSalesforceProvider => Type.Equals("Salesforce", StringComparison.OrdinalIgnoreCase); + } + + public class HelpCenterProviderConfiguration + { + // Common settings + public string? BaseUrl { get; set; } + public string? ApiVersion { get; set; } + public string? DefaultLocale { get; set; } = "en-us"; + + // Zendesk specific + public string? Subdomain { get; set; } + public string? Username { get; set; } + public string? ApiKey { get; set; } + public string? Token { get; set; } + + // Salesforce specific + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public string? SecurityToken { get; set; } + public string? LoginUrl { get; set; } + + // Template variables + public Dictionary DefaultTemplateVariables { get; set; } = new() + { + ["VERSION"] = "{{VERSION}}", + ["SWURL"] = "{{SWURL}}", + ["PDFURL"] = "{{PDFURL}}", + ["DATE"] = "{{DATE}}", + ["PROJECT"] = "{{PROJECT}}" + }; + + // Additional settings + public Dictionary AdditionalSettings { get; set; } = new(); + public Dictionary Headers { get; set; } = new(); + } +} \ No newline at end of file diff --git a/src/Database/Models/Package.cs b/src/Database/Models/Package.cs new file mode 100644 index 0000000..e4d6db8 --- /dev/null +++ b/src/Database/Models/Package.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Database.Models +{ + public class Package : BaseEntity + { + [Required] + [MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string Version { get; set; } = string.Empty; + + public string? Description { get; set; } + + public int ProjectId { get; set; } + + public int? SourceBuildId { get; set; } + + [Required] + [MaxLength(50)] + public string Status { get; set; } = "Draft"; // Draft, Ready, Publishing, Published, Failed + + public DateTime? PublishDate { get; set; } + + // Navigation properties + [ForeignKey(nameof(ProjectId))] + public Project Project { get; set; } = null!; + + [ForeignKey(nameof(SourceBuildId))] + public Build? SourceBuild { get; set; } + + public PackageConfiguration? Configuration { get; set; } + + public ICollection Publications { get; set; } = new List(); + + // Helper properties + public bool IsPublished => Status == "Published" && PublishDate.HasValue; + + public bool IsReadyToPublish => Status == "Ready" && SourceBuildId.HasValue; + } +} \ No newline at end of file diff --git a/src/Database/Models/PackageConfiguration.cs b/src/Database/Models/PackageConfiguration.cs new file mode 100644 index 0000000..0afefe3 --- /dev/null +++ b/src/Database/Models/PackageConfiguration.cs @@ -0,0 +1,92 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + +namespace Database.Models +{ + public class PackageConfiguration : BaseEntity + { + public int PackageId { get; set; } + + public string? BuildFolder { get; set; } + + public bool ZipContents { get; set; } = false; + + public bool DeleteOldPublishedBuilds { get; set; } = false; + + public string? ReleaseNoteTemplate { get; set; } + + // JSON columns for PostgreSQL + [Column(TypeName = "jsonb")] + public string? StorageSettings { get; set; } + + [Column(TypeName = "jsonb")] + public string? HelpCenterSettings { get; set; } + + // Navigation properties + [ForeignKey(nameof(PackageId))] + public Package Package { get; set; } = null!; + + // Helper methods for StorageSettings JSON + public StorageSettingsModel GetStorageSettings() + { + if (string.IsNullOrEmpty(StorageSettings)) + return new StorageSettingsModel(); + + try + { + return JsonSerializer.Deserialize(StorageSettings) ?? new StorageSettingsModel(); + } + catch + { + return new StorageSettingsModel(); + } + } + + public void SetStorageSettings(StorageSettingsModel settings) + { + StorageSettings = JsonSerializer.Serialize(settings); + } + + // Helper methods for HelpCenterSettings JSON + public HelpCenterSettingsModel GetHelpCenterSettings() + { + if (string.IsNullOrEmpty(HelpCenterSettings)) + return new HelpCenterSettingsModel(); + + try + { + return JsonSerializer.Deserialize(HelpCenterSettings) ?? new HelpCenterSettingsModel(); + } + catch + { + return new HelpCenterSettingsModel(); + } + } + + public void SetHelpCenterSettings(HelpCenterSettingsModel settings) + { + HelpCenterSettings = JsonSerializer.Serialize(settings); + } + } + + public class StorageSettingsModel + { + public string? ProviderName { get; set; } + public string? ContainerName { get; set; } + public string? FolderPath { get; set; } + public bool EnableCDN { get; set; } = false; + public int? MaxVersionsToKeep { get; set; } + public Dictionary AdditionalSettings { get; set; } = new(); + } + + public class HelpCenterSettingsModel + { + public string? ProviderName { get; set; } + public string? ArticleId { get; set; } + public string? ArticleTemplate { get; set; } + public string? Locale { get; set; } = "en-us"; + public Dictionary TemplateVariables { get; set; } = new(); + public Dictionary AdditionalSettings { get; set; } = new(); + } +} \ No newline at end of file diff --git a/src/Database/Models/Project.cs b/src/Database/Models/Project.cs new file mode 100644 index 0000000..6b79396 --- /dev/null +++ b/src/Database/Models/Project.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Database.Models +{ + public class Project : BaseEntity + { + [Required] + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + [Required] + [MaxLength(200)] + public string CCNetProjectName { get; set; } = string.Empty; + + [MaxLength(50)] + public string Status { get; set; } = "Active"; // Active, Inactive, Archived + + // Navigation properties + public ICollection Builds { get; set; } = new List(); + public ICollection Packages { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/src/Database/Models/Publication.cs b/src/Database/Models/Publication.cs new file mode 100644 index 0000000..0997773 --- /dev/null +++ b/src/Database/Models/Publication.cs @@ -0,0 +1,75 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + +namespace Database.Models +{ + public class Publication : BaseEntity + { + public int PackageId { get; set; } + + [Required] + [MaxLength(50)] + public string Status { get; set; } = "Pending"; // Pending, InProgress, Completed, Failed, Cancelled + + public DateTime? PublishedAt { get; set; } + + public string? ReleaseNotesPath { get; set; } + + // JSON column for PostgreSQL + [Column(TypeName = "jsonb")] + public string? PublicationDetails { get; set; } + + public string? ErrorMessage { get; set; } + + public TimeSpan? Duration => PublishedAt?.Subtract(CreatedAt); + + // Navigation properties + [ForeignKey(nameof(PackageId))] + public Package Package { get; set; } = null!; + + public ICollection Steps { get; set; } = new List(); + + // Helper methods for PublicationDetails JSON + public PublicationDetailsModel GetPublicationDetails() + { + if (string.IsNullOrEmpty(PublicationDetails)) + return new PublicationDetailsModel(); + + try + { + return JsonSerializer.Deserialize(PublicationDetails) ?? new PublicationDetailsModel(); + } + catch + { + return new PublicationDetailsModel(); + } + } + + public void SetPublicationDetails(PublicationDetailsModel details) + { + PublicationDetails = JsonSerializer.Serialize(details); + } + + // Helper properties + public bool IsCompleted => Status == "Completed" && PublishedAt.HasValue; + + public bool IsFailed => Status == "Failed"; + + public bool IsInProgress => Status == "InProgress"; + + public int ProgressPercentage => Steps.Any() ? + (int)(Steps.Count(s => s.Status == "Completed") * 100.0 / Steps.Count) : 0; + } + + public class PublicationDetailsModel + { + public string? BuildVersion { get; set; } + public string? ReleaseNotesUrl { get; set; } + public string? PackageUrl { get; set; } + public string? CDNUrl { get; set; } + public List UploadedFiles { get; set; } = new(); + public List UpdatedArticles { get; set; } = new(); + public Dictionary AdditionalDetails { get; set; } = new(); + } +} \ No newline at end of file diff --git a/src/Database/Models/PublishingStep.cs b/src/Database/Models/PublishingStep.cs new file mode 100644 index 0000000..72fc92d --- /dev/null +++ b/src/Database/Models/PublishingStep.cs @@ -0,0 +1,104 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Database.Models +{ + public class PublishingStep : BaseEntity + { + public int PublicationId { get; set; } + + [Required] + [MaxLength(200)] + public string StepName { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string Status { get; set; } = "Pending"; // Pending, InProgress, Completed, Failed, Skipped + + public string? Details { get; set; } + + public DateTime? StartedAt { get; set; } + + public DateTime? CompletedAt { get; set; } + + [Range(0, 100)] + public int ProgressPercent { get; set; } = 0; + + public string? ErrorMessage { get; set; } + + public int StepOrder { get; set; } + + // Navigation properties + [ForeignKey(nameof(PublicationId))] + public Publication Publication { get; set; } = null!; + + // Helper properties + public TimeSpan? Duration => CompletedAt?.Subtract(StartedAt ?? CreatedAt); + + public bool IsCompleted => Status == "Completed" && CompletedAt.HasValue; + + public bool IsFailed => Status == "Failed"; + + public bool IsInProgress => Status == "InProgress"; + + public bool IsPending => Status == "Pending"; + + public void Start() + { + Status = "InProgress"; + StartedAt = DateTime.UtcNow; + UpdateTimestamp("System"); + } + + public void Complete(string? details = null) + { + Status = "Completed"; + CompletedAt = DateTime.UtcNow; + ProgressPercent = 100; + if (details != null) + Details = details; + UpdateTimestamp("System"); + } + + public void Fail(string errorMessage, string? details = null) + { + Status = "Failed"; + CompletedAt = DateTime.UtcNow; + ErrorMessage = errorMessage; + if (details != null) + Details = details; + UpdateTimestamp("System"); + } + + public void Skip(string reason) + { + Status = "Skipped"; + CompletedAt = DateTime.UtcNow; + Details = reason; + ProgressPercent = 100; + UpdateTimestamp("System"); + } + + public void UpdateProgress(int percent, string? details = null) + { + ProgressPercent = Math.Max(0, Math.Min(100, percent)); + if (details != null) + Details = details; + UpdateTimestamp("System"); + } + } + + public static class PublishingStepNames + { + public const string ValidateConfiguration = "Validate Configuration"; + public const string CollectBuildData = "Collect Build Data"; + public const string QueryFogBugz = "Query FogBugz Data"; + public const string GenerateReleaseNotes = "Generate Release Notes"; + public const string CompressFiles = "Compress Files"; + public const string UploadToStorage = "Upload to Storage"; + public const string CleanupOldVersions = "Cleanup Old Versions"; + public const string UpdateHelpCenter = "Update Help Center"; + public const string UpdatePackageStatus = "Update Package Status"; + public const string SendNotification = "Send Notification"; + } +} \ No newline at end of file diff --git a/src/Database/Models/StorageProvider.cs b/src/Database/Models/StorageProvider.cs new file mode 100644 index 0000000..77a8f7a --- /dev/null +++ b/src/Database/Models/StorageProvider.cs @@ -0,0 +1,90 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + +namespace Database.Models +{ + public class StorageProvider : BaseEntity + { + [Required] + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string Type { get; set; } = string.Empty; // S3, Box, FTP + + // JSON column for PostgreSQL + [Column(TypeName = "jsonb")] + public string? Configuration { get; set; } + + public bool IsActive { get; set; } = true; + + public string? Description { get; set; } + + public DateTime? LastTested { get; set; } + + public bool? LastTestResult { get; set; } + + public string? LastTestError { get; set; } + + // Helper methods for Configuration JSON + public StorageProviderConfiguration GetConfiguration() + { + if (string.IsNullOrEmpty(Configuration)) + return new StorageProviderConfiguration(); + + try + { + return JsonSerializer.Deserialize(Configuration) ?? new StorageProviderConfiguration(); + } + catch + { + return new StorageProviderConfiguration(); + } + } + + public void SetConfiguration(StorageProviderConfiguration config) + { + Configuration = JsonSerializer.Serialize(config); + } + + // Helper properties + public bool IsS3Provider => Type.Equals("S3", StringComparison.OrdinalIgnoreCase); + + public bool IsBoxProvider => Type.Equals("Box", StringComparison.OrdinalIgnoreCase); + + public bool IsFtpProvider => Type.Equals("FTP", StringComparison.OrdinalIgnoreCase); + } + + public class StorageProviderConfiguration + { + // Common settings + public string? ConnectionString { get; set; } + public string? Region { get; set; } + public string? Bucket { get; set; } + public string? Container { get; set; } + + // AWS S3 specific + public string? AccessKey { get; set; } + public string? SecretKey { get; set; } + public string? CloudFrontDomain { get; set; } + + // Box specific + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + + // FTP specific + public string? Host { get; set; } + public int? Port { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public bool? UseSSL { get; set; } + + // Additional settings + public Dictionary AdditionalSettings { get; set; } = new(); + public Dictionary Headers { get; set; } = new(); + } +} \ No newline at end of file diff --git a/src/Database/Models/User.cs b/src/Database/Models/User.cs new file mode 100644 index 0000000..8c2a13c --- /dev/null +++ b/src/Database/Models/User.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Database.Models +{ + public class User : BaseEntity + { + [Required] + [MaxLength(100)] + public string Username { get; set; } = string.Empty; + + [Required] + public string PasswordHash { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string Role { get; set; } = "User"; // Admin, User, ReadOnly + + public DateTime? LastLogin { get; set; } + + public string? Email { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public bool IsActive { get; set; } = true; + + public string FullName => $"{FirstName} {LastName}".Trim(); + } +} \ No newline at end of file diff --git a/src/Database/Repositories/IBuildRepository.cs b/src/Database/Repositories/IBuildRepository.cs new file mode 100644 index 0000000..72a56c7 --- /dev/null +++ b/src/Database/Repositories/IBuildRepository.cs @@ -0,0 +1,129 @@ +using Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Database.Services +{ + public interface IBuildRepository + { + Task GetByIdAsync(int id); + Task GetByProjectAndBuildNumberAsync(int projectId, string buildNumber); + Task> GetByProjectIdAsync(int projectId); + Task> GetSuccessfulBuildsByProjectIdAsync(int projectId); + Task> GetRecentBuildsAsync(int count = 50); + Task CreateAsync(Build build); + Task UpdateAsync(Build build); + Task DeleteAsync(int id, string deletedBy); + Task ExistsAsync(int id); + Task> GetCommitsByBuildIdAsync(int buildId); + Task CreateCommitAsync(BuildCommit commit); + Task> CreateCommitsAsync(List commits); + } + + public class BuildRepository : IBuildRepository + { + private readonly DatabaseContext _context; + + public BuildRepository(DatabaseContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id) + { + return await _context.Builds + .Include(b => b.Project) + .Include(b => b.Commits) + .ThenInclude(c => c.FogBugzCase) + .FirstOrDefaultAsync(b => b.Id == id); + } + + public async Task GetByProjectAndBuildNumberAsync(int projectId, string buildNumber) + { + return await _context.Builds + .Include(b => b.Project) + .Include(b => b.Commits) + .FirstOrDefaultAsync(b => b.ProjectId == projectId && b.BuildNumber == buildNumber); + } + + public async Task> GetByProjectIdAsync(int projectId) + { + return await _context.Builds + .Include(b => b.Project) + .Where(b => b.ProjectId == projectId) + .OrderByDescending(b => b.StartTime) + .ToListAsync(); + } + + public async Task> GetSuccessfulBuildsByProjectIdAsync(int projectId) + { + return await _context.Builds + .Include(b => b.Project) + .Where(b => b.ProjectId == projectId && b.Status == "Success") + .OrderByDescending(b => b.StartTime) + .ToListAsync(); + } + + public async Task> GetRecentBuildsAsync(int count = 50) + { + return await _context.Builds + .Include(b => b.Project) + .OrderByDescending(b => b.StartTime) + .Take(count) + .ToListAsync(); + } + + public async Task CreateAsync(Build build) + { + _context.Builds.Add(build); + await _context.SaveChangesAsync(); + return build; + } + + public async Task UpdateAsync(Build build) + { + build.UpdateTimestamp(build.UpdatedBy ?? "System"); + _context.Builds.Update(build); + await _context.SaveChangesAsync(); + return build; + } + + public async Task DeleteAsync(int id, string deletedBy) + { + var build = await GetByIdAsync(id); + if (build != null) + { + build.MarkAsDeleted(deletedBy); + await _context.SaveChangesAsync(); + } + } + + public async Task ExistsAsync(int id) + { + return await _context.Builds.AnyAsync(b => b.Id == id); + } + + public async Task> GetCommitsByBuildIdAsync(int buildId) + { + return await _context.BuildCommits + .Include(c => c.Build) + .Include(c => c.FogBugzCase) + .Where(c => c.BuildId == buildId) + .OrderByDescending(c => c.CommitDate) + .ToListAsync(); + } + + public async Task CreateCommitAsync(BuildCommit commit) + { + _context.BuildCommits.Add(commit); + await _context.SaveChangesAsync(); + return commit; + } + + public async Task> CreateCommitsAsync(List commits) + { + _context.BuildCommits.AddRange(commits); + await _context.SaveChangesAsync(); + return commits; + } + } +} \ No newline at end of file diff --git a/src/Database/Repositories/IFogBugzRepository.cs b/src/Database/Repositories/IFogBugzRepository.cs new file mode 100644 index 0000000..6501a10 --- /dev/null +++ b/src/Database/Repositories/IFogBugzRepository.cs @@ -0,0 +1,158 @@ +using Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Database.Services +{ + public interface IFogBugzRepository + { + Task GetCaseByIdAsync(int caseId); + Task GetCaseByDbIdAsync(int id); + Task> GetCasesByStatusAsync(string status); + Task> GetRecentCasesAsync(int count = 50); + Task CreateOrUpdateCaseAsync(FogBugzCase fogBugzCase); + Task DeleteCaseAsync(int id, string deletedBy); + Task CaseExistsAsync(int caseId); + + Task> GetEventsByCaseIdAsync(int caseId); + Task CreateEventAsync(FogBugzEvent fogBugzEvent); + Task> CreateEventsAsync(List events); + Task> GetReleaseNoteEventsAsync(int caseId); + Task> GetZendeskEventsAsync(int caseId); + } + + public class FogBugzRepository : IFogBugzRepository + { + private readonly DatabaseContext _context; + + public FogBugzRepository(DatabaseContext context) + { + _context = context; + } + + public async Task GetCaseByIdAsync(int caseId) + { + return await _context.FogBugzCases + .Include(c => c.Events.OrderByDescending(e => e.EventDate)) + .FirstOrDefaultAsync(c => c.CaseId == caseId); + } + + public async Task GetCaseByDbIdAsync(int id) + { + return await _context.FogBugzCases + .Include(c => c.Events.OrderByDescending(e => e.EventDate)) + .FirstOrDefaultAsync(c => c.Id == id); + } + + public async Task> GetCasesByStatusAsync(string status) + { + return await _context.FogBugzCases + .Where(c => c.Status == status) + .OrderByDescending(c => c.LastUpdated) + .ToListAsync(); + } + + public async Task> GetRecentCasesAsync(int count = 50) + { + return await _context.FogBugzCases + .OrderByDescending(c => c.LastUpdated) + .Take(count) + .ToListAsync(); + } + + public async Task CreateOrUpdateCaseAsync(FogBugzCase fogBugzCase) + { + var existing = await GetCaseByIdAsync(fogBugzCase.CaseId); + + if (existing != null) + { + existing.Title = fogBugzCase.Title; + existing.Project = fogBugzCase.Project; + existing.Area = fogBugzCase.Area; + existing.Status = fogBugzCase.Status; + existing.LastUpdated = fogBugzCase.LastUpdated; + existing.IsOpen = fogBugzCase.IsOpen; + existing.UpdateTimestamp(fogBugzCase.UpdatedBy ?? "System"); + + _context.FogBugzCases.Update(existing); + await _context.SaveChangesAsync(); + return existing; + } + else + { + _context.FogBugzCases.Add(fogBugzCase); + await _context.SaveChangesAsync(); + return fogBugzCase; + } + } + + public async Task DeleteCaseAsync(int id, string deletedBy) + { + var fogBugzCase = await GetCaseByDbIdAsync(id); + if (fogBugzCase != null) + { + fogBugzCase.MarkAsDeleted(deletedBy); + await _context.SaveChangesAsync(); + } + } + + public async Task CaseExistsAsync(int caseId) + { + return await _context.FogBugzCases.AnyAsync(c => c.CaseId == caseId); + } + + public async Task> GetEventsByCaseIdAsync(int caseId) + { + var fogBugzCase = await GetCaseByIdAsync(caseId); + if (fogBugzCase == null) + return new List(); + + return await _context.FogBugzEvents + .Where(e => e.CaseId == fogBugzCase.Id) + .OrderByDescending(e => e.EventDate) + .ToListAsync(); + } + + public async Task CreateEventAsync(FogBugzEvent fogBugzEvent) + { + _context.FogBugzEvents.Add(fogBugzEvent); + await _context.SaveChangesAsync(); + return fogBugzEvent; + } + + public async Task> CreateEventsAsync(List events) + { + _context.FogBugzEvents.AddRange(events); + await _context.SaveChangesAsync(); + return events; + } + + public async Task> GetReleaseNoteEventsAsync(int caseId) + { + var fogBugzCase = await GetCaseByIdAsync(caseId); + if (fogBugzCase == null) + return new List(); + + return await _context.FogBugzEvents + .Where(e => e.CaseId == fogBugzCase.Id && + e.EventType == "ReleaseNoted" && + !string.IsNullOrEmpty(e.ReleaseNote)) + .OrderByDescending(e => e.EventDate) + .ToListAsync(); + } + + public async Task> GetZendeskEventsAsync(int caseId) + { + var fogBugzCase = await GetCaseByIdAsync(caseId); + if (fogBugzCase == null) + return new List(); + + return await _context.FogBugzEvents + .Where(e => e.CaseId == fogBugzCase.Id && + e.EventType == "sZendesk" && + e.ZendeskNumber.HasValue && + e.ZendeskNumber > 0) + .OrderByDescending(e => e.EventDate) + .ToListAsync(); + } + } +} \ No newline at end of file diff --git a/src/Database/Repositories/IPackageRepository.cs b/src/Database/Repositories/IPackageRepository.cs new file mode 100644 index 0000000..1931b07 --- /dev/null +++ b/src/Database/Repositories/IPackageRepository.cs @@ -0,0 +1,130 @@ +using Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Database.Services +{ + public interface IPackageRepository + { + Task GetByIdAsync(int id); + Task> GetAllAsync(); + Task> GetByProjectIdAsync(int projectId); + Task> GetByStatusAsync(string status); + Task CreateAsync(Package package); + Task UpdateAsync(Package package); + Task DeleteAsync(int id, string deletedBy); + Task ExistsAsync(int id); + Task GetConfigurationAsync(int packageId); + Task SaveConfigurationAsync(PackageConfiguration config); + } + + public class PackageRepository : IPackageRepository + { + private readonly DatabaseContext _context; + + public PackageRepository(DatabaseContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id) + { + return await _context.Packages + .Include(p => p.Project) + .Include(p => p.SourceBuild) + .Include(p => p.Configuration) + .Include(p => p.Publications) + .FirstOrDefaultAsync(p => p.Id == id); + } + + public async Task> GetAllAsync() + { + return await _context.Packages + .Include(p => p.Project) + .Include(p => p.SourceBuild) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(); + } + + public async Task> GetByProjectIdAsync(int projectId) + { + return await _context.Packages + .Include(p => p.Project) + .Include(p => p.SourceBuild) + .Where(p => p.ProjectId == projectId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(); + } + + public async Task> GetByStatusAsync(string status) + { + return await _context.Packages + .Include(p => p.Project) + .Include(p => p.SourceBuild) + .Where(p => p.Status == status) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(); + } + + public async Task CreateAsync(Package package) + { + _context.Packages.Add(package); + await _context.SaveChangesAsync(); + return package; + } + + public async Task UpdateAsync(Package package) + { + package.UpdateTimestamp(package.UpdatedBy ?? "System"); + _context.Packages.Update(package); + await _context.SaveChangesAsync(); + return package; + } + + public async Task DeleteAsync(int id, string deletedBy) + { + var package = await GetByIdAsync(id); + if (package != null) + { + package.MarkAsDeleted(deletedBy); + await _context.SaveChangesAsync(); + } + } + + public async Task ExistsAsync(int id) + { + return await _context.Packages.AnyAsync(p => p.Id == id); + } + + public async Task GetConfigurationAsync(int packageId) + { + return await _context.PackageConfigurations + .FirstOrDefaultAsync(pc => pc.PackageId == packageId); + } + + public async Task SaveConfigurationAsync(PackageConfiguration config) + { + var existing = await GetConfigurationAsync(config.PackageId); + + if (existing != null) + { + existing.BuildFolder = config.BuildFolder; + existing.ZipContents = config.ZipContents; + existing.DeleteOldPublishedBuilds = config.DeleteOldPublishedBuilds; + existing.ReleaseNoteTemplate = config.ReleaseNoteTemplate; + existing.StorageSettings = config.StorageSettings; + existing.HelpCenterSettings = config.HelpCenterSettings; + existing.UpdateTimestamp(config.UpdatedBy ?? "System"); + + _context.PackageConfigurations.Update(existing); + await _context.SaveChangesAsync(); + return existing; + } + else + { + _context.PackageConfigurations.Add(config); + await _context.SaveChangesAsync(); + return config; + } + } + } +} \ No newline at end of file diff --git a/src/Database/Repositories/IProjectRepository.cs b/src/Database/Repositories/IProjectRepository.cs new file mode 100644 index 0000000..4697d0a --- /dev/null +++ b/src/Database/Repositories/IProjectRepository.cs @@ -0,0 +1,96 @@ +using Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Database.Services +{ + public interface IProjectRepository + { + Task GetByIdAsync(int id); + Task GetByCCNetNameAsync(string ccNetProjectName); + Task> GetAllAsync(); + Task> GetActiveProjectsAsync(); + Task CreateAsync(Project project); + Task UpdateAsync(Project project); + Task DeleteAsync(int id, string deletedBy); + Task ExistsAsync(int id); + Task CCNetNameExistsAsync(string ccNetProjectName); + } + + public class ProjectRepository : IProjectRepository + { + private readonly DatabaseContext _context; + + public ProjectRepository(DatabaseContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id) + { + return await _context.Projects + .Include(p => p.Builds) + .Include(p => p.Packages) + .FirstOrDefaultAsync(p => p.Id == id); + } + + public async Task GetByCCNetNameAsync(string ccNetProjectName) + { + return await _context.Projects + .Include(p => p.Builds) + .Include(p => p.Packages) + .FirstOrDefaultAsync(p => p.CCNetProjectName == ccNetProjectName); + } + + public async Task> GetAllAsync() + { + return await _context.Projects + .Include(p => p.Builds.Take(5)) + .Include(p => p.Packages.Take(5)) + .OrderBy(p => p.Name) + .ToListAsync(); + } + + public async Task> GetActiveProjectsAsync() + { + return await _context.Projects + .Where(p => p.Status == "Active") + .OrderBy(p => p.Name) + .ToListAsync(); + } + + public async Task CreateAsync(Project project) + { + _context.Projects.Add(project); + await _context.SaveChangesAsync(); + return project; + } + + public async Task UpdateAsync(Project project) + { + project.UpdateTimestamp(project.UpdatedBy ?? "System"); + _context.Projects.Update(project); + await _context.SaveChangesAsync(); + return project; + } + + public async Task DeleteAsync(int id, string deletedBy) + { + var project = await GetByIdAsync(id); + if (project != null) + { + project.MarkAsDeleted(deletedBy); + await _context.SaveChangesAsync(); + } + } + + public async Task ExistsAsync(int id) + { + return await _context.Projects.AnyAsync(p => p.Id == id); + } + + public async Task CCNetNameExistsAsync(string ccNetProjectName) + { + return await _context.Projects.AnyAsync(p => p.CCNetProjectName == ccNetProjectName); + } + } +} \ No newline at end of file diff --git a/src/Database/Repositories/IPublicationRepository.cs b/src/Database/Repositories/IPublicationRepository.cs new file mode 100644 index 0000000..0e873ce --- /dev/null +++ b/src/Database/Repositories/IPublicationRepository.cs @@ -0,0 +1,168 @@ +using Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Database.Services +{ + public interface IPublicationRepository + { + Task GetByIdAsync(int id); + Task> GetByPackageIdAsync(int packageId); + Task> GetByStatusAsync(string status); + Task> GetRecentPublicationsAsync(int count = 50); + Task CreateAsync(Publication publication); + Task UpdateAsync(Publication publication); + Task DeleteAsync(int id, string deletedBy); + Task ExistsAsync(int id); + + Task> GetStepsByPublicationIdAsync(int publicationId); + Task GetStepByIdAsync(int stepId); + Task CreateStepAsync(PublishingStep step); + Task UpdateStepAsync(PublishingStep step); + Task> CreateStepsAsync(List steps); + Task GetCurrentStepAsync(int publicationId); + Task> GetCompletedStepsAsync(int publicationId); + Task> GetFailedStepsAsync(int publicationId); + } + + public class PublicationRepository : IPublicationRepository + { + private readonly DatabaseContext _context; + + public PublicationRepository(DatabaseContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id) + { + return await _context.Publications + .Include(p => p.Package) + .ThenInclude(pkg => pkg.Project) + .Include(p => p.Steps.OrderBy(s => s.StepOrder)) + .FirstOrDefaultAsync(p => p.Id == id); + } + + public async Task> GetByPackageIdAsync(int packageId) + { + return await _context.Publications + .Include(p => p.Package) + .Include(p => p.Steps) + .Where(p => p.PackageId == packageId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(); + } + + public async Task> GetByStatusAsync(string status) + { + return await _context.Publications + .Include(p => p.Package) + .ThenInclude(pkg => pkg.Project) + .Where(p => p.Status == status) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(); + } + + public async Task> GetRecentPublicationsAsync(int count = 50) + { + return await _context.Publications + .Include(p => p.Package) + .ThenInclude(pkg => pkg.Project) + .OrderByDescending(p => p.CreatedAt) + .Take(count) + .ToListAsync(); + } + + public async Task CreateAsync(Publication publication) + { + _context.Publications.Add(publication); + await _context.SaveChangesAsync(); + return publication; + } + + public async Task UpdateAsync(Publication publication) + { + publication.UpdateTimestamp(publication.UpdatedBy ?? "System"); + _context.Publications.Update(publication); + await _context.SaveChangesAsync(); + return publication; + } + + public async Task DeleteAsync(int id, string deletedBy) + { + var publication = await GetByIdAsync(id); + if (publication != null) + { + publication.MarkAsDeleted(deletedBy); + await _context.SaveChangesAsync(); + } + } + + public async Task ExistsAsync(int id) + { + return await _context.Publications.AnyAsync(p => p.Id == id); + } + + public async Task> GetStepsByPublicationIdAsync(int publicationId) + { + return await _context.PublishingSteps + .Include(s => s.Publication) + .Where(s => s.PublicationId == publicationId) + .OrderBy(s => s.StepOrder) + .ToListAsync(); + } + + public async Task GetStepByIdAsync(int stepId) + { + return await _context.PublishingSteps + .Include(s => s.Publication) + .FirstOrDefaultAsync(s => s.Id == stepId); + } + + public async Task CreateStepAsync(PublishingStep step) + { + _context.PublishingSteps.Add(step); + await _context.SaveChangesAsync(); + return step; + } + + public async Task UpdateStepAsync(PublishingStep step) + { + step.UpdateTimestamp(step.UpdatedBy ?? "System"); + _context.PublishingSteps.Update(step); + await _context.SaveChangesAsync(); + return step; + } + + public async Task> CreateStepsAsync(List steps) + { + _context.PublishingSteps.AddRange(steps); + await _context.SaveChangesAsync(); + return steps; + } + + public async Task GetCurrentStepAsync(int publicationId) + { + return await _context.PublishingSteps + .Where(s => s.PublicationId == publicationId && + (s.Status == "InProgress" || s.Status == "Pending")) + .OrderBy(s => s.StepOrder) + .FirstOrDefaultAsync(); + } + + public async Task> GetCompletedStepsAsync(int publicationId) + { + return await _context.PublishingSteps + .Where(s => s.PublicationId == publicationId && s.Status == "Completed") + .OrderBy(s => s.StepOrder) + .ToListAsync(); + } + + public async Task> GetFailedStepsAsync(int publicationId) + { + return await _context.PublishingSteps + .Where(s => s.PublicationId == publicationId && s.Status == "Failed") + .OrderBy(s => s.StepOrder) + .ToListAsync(); + } + } +} \ No newline at end of file diff --git a/src/Database/Repositories/IUserRepository.cs b/src/Database/Repositories/IUserRepository.cs new file mode 100644 index 0000000..3d91e18 --- /dev/null +++ b/src/Database/Repositories/IUserRepository.cs @@ -0,0 +1,113 @@ +using Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Database.Services +{ + public interface IUserRepository + { + Task GetByIdAsync(int id); + Task GetByUsernameAsync(string username); + Task GetByEmailAsync(string email); + Task> GetAllAsync(); + Task> GetActiveUsersAsync(); + Task CreateAsync(User user); + Task UpdateAsync(User user); + Task DeleteAsync(int id, string deletedBy); + Task ExistsAsync(int id); + Task UsernameExistsAsync(string username); + Task EmailExistsAsync(string email); + Task UpdateLastLoginAsync(int userId); + } + + public class UserRepository : IUserRepository + { + private readonly DatabaseContext _context; + + public UserRepository(DatabaseContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id) + { + return await _context.Users.FindAsync(id); + } + + public async Task GetByUsernameAsync(string username) + { + return await _context.Users + .FirstOrDefaultAsync(u => u.Username == username); + } + + public async Task GetByEmailAsync(string email) + { + return await _context.Users + .FirstOrDefaultAsync(u => u.Email == email); + } + + public async Task> GetAllAsync() + { + return await _context.Users + .OrderBy(u => u.Username) + .ToListAsync(); + } + + public async Task> GetActiveUsersAsync() + { + return await _context.Users + .Where(u => u.IsActive) + .OrderBy(u => u.Username) + .ToListAsync(); + } + + public async Task CreateAsync(User user) + { + _context.Users.Add(user); + await _context.SaveChangesAsync(); + return user; + } + + public async Task UpdateAsync(User user) + { + user.UpdateTimestamp(user.UpdatedBy ?? "System"); + _context.Users.Update(user); + await _context.SaveChangesAsync(); + return user; + } + + public async Task DeleteAsync(int id, string deletedBy) + { + var user = await GetByIdAsync(id); + if (user != null) + { + user.MarkAsDeleted(deletedBy); + await _context.SaveChangesAsync(); + } + } + + public async Task ExistsAsync(int id) + { + return await _context.Users.AnyAsync(u => u.Id == id); + } + + public async Task UsernameExistsAsync(string username) + { + return await _context.Users.AnyAsync(u => u.Username == username); + } + + public async Task EmailExistsAsync(string email) + { + return await _context.Users.AnyAsync(u => u.Email == email); + } + + public async Task UpdateLastLoginAsync(int userId) + { + var user = await GetByIdAsync(userId); + if (user != null) + { + user.LastLogin = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/Database/Services/DatabaseMigrator.cs b/src/Database/Services/DatabaseMigrator.cs new file mode 100644 index 0000000..f02fbfe --- /dev/null +++ b/src/Database/Services/DatabaseMigrator.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Database.Configuration; + +namespace Database.Services +{ + public interface IDatabaseMigrator + { + Task MigrateAsync(); + Task CanConnectAsync(); + Task> GetPendingMigrationsAsync(); + Task> GetAppliedMigrationsAsync(); + } + + public class DatabaseMigrator : IDatabaseMigrator + { + private readonly DatabaseContext _context; + private readonly DatabaseConfiguration _config; + private readonly ILogger _logger; + + public DatabaseMigrator(DatabaseContext context, DatabaseConfiguration config, ILogger logger) + { + _context = context; + _config = config; + _logger = logger; + } + + public async Task MigrateAsync() + { + try + { + _logger.LogInformation("Starting database migration..."); + + var pendingMigrations = await _context.Database.GetPendingMigrationsAsync(); + + if (pendingMigrations.Any()) + { + _logger.LogInformation("Found {Count} pending migrations: {Migrations}", + pendingMigrations.Count(), string.Join(", ", pendingMigrations)); + + await _context.Database.MigrateAsync(); + + _logger.LogInformation("Database migration completed successfully"); + } + else + { + _logger.LogInformation("No pending migrations found"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Database migration failed"); + throw; + } + } + + public async Task CanConnectAsync() + { + try + { + return await _context.Database.CanConnectAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to connect to database"); + return false; + } + } + + public async Task> GetPendingMigrationsAsync() + { + try + { + var pending = await _context.Database.GetPendingMigrationsAsync(); + return pending.ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting pending migrations"); + return new List(); + } + } + + public async Task> GetAppliedMigrationsAsync() + { + try + { + var applied = await _context.Database.GetAppliedMigrationsAsync(); + return applied.ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting applied migrations"); + return new List(); + } + } + } +} \ No newline at end of file diff --git a/src/Database/Services/DatabaseSeeder.cs b/src/Database/Services/DatabaseSeeder.cs new file mode 100644 index 0000000..a615f2f --- /dev/null +++ b/src/Database/Services/DatabaseSeeder.cs @@ -0,0 +1,312 @@ +using Microsoft.Extensions.Logging; +using Database.Models; + +namespace Database.Services +{ + public interface IDatabaseSeeder + { + Task SeedAsync(); + Task SeedUsersAsync(); + Task SeedProvidersAsync(); + Task SeedTestDataAsync(); + } + + public class DatabaseSeeder : IDatabaseSeeder + { + private readonly DatabaseContext _context; + private readonly IPasswordHasher _passwordHasher; + private readonly ILogger _logger; + + public DatabaseSeeder(DatabaseContext context, IPasswordHasher passwordHasher, ILogger logger) + { + _context = context; + _passwordHasher = passwordHasher; + _logger = logger; + } + + public async Task SeedAsync() + { + try + { + _logger.LogInformation("Starting database seeding..."); + + await SeedUsersAsync(); + await SeedProvidersAsync(); + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Database seeding completed successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Database seeding failed"); + throw; + } + } + + public async Task SeedUsersAsync() + { + if (!_context.Users.Any()) + { + _logger.LogInformation("Seeding default users..."); + + var adminUser = new User + { + Username = "admin", + PasswordHash = _passwordHasher.HashPassword("admin123"), + Role = "Admin", + Email = "admin@company.com", + FirstName = "System", + LastName = "Administrator", + IsActive = true, + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }; + + var testUser = new User + { + Username = "user", + PasswordHash = _passwordHasher.HashPassword("user123"), + Role = "User", + Email = "user@company.com", + FirstName = "Test", + LastName = "User", + IsActive = true, + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }; + + var readOnlyUser = new User + { + Username = "readonly", + PasswordHash = _passwordHasher.HashPassword("readonly123"), + Role = "ReadOnly", + Email = "readonly@company.com", + FirstName = "ReadOnly", + LastName = "User", + IsActive = true, + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }; + + _context.Users.AddRange(adminUser, testUser, readOnlyUser); + + _logger.LogInformation("Added {Count} default users", 3); + } + } + + public async Task SeedProvidersAsync() + { + await SeedStorageProvidersAsync(); + await SeedHelpCenterProvidersAsync(); + } + + private async Task SeedStorageProvidersAsync() + { + if (!_context.StorageProviders.Any()) + { + _logger.LogInformation("Seeding default storage providers..."); + + var s3Provider = new StorageProvider + { + Name = "AWS S3", + Type = "S3", + Description = "Amazon Web Services S3 Storage", + IsActive = true, + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }; + + s3Provider.SetConfiguration(new StorageProviderConfiguration + { + Region = "us-east-2", + Bucket = "software-releases", + CloudFrontDomain = "https://cdn.company.com/", + AdditionalSettings = new Dictionary + { + ["EnableCDN"] = true, + ["EnableVersioning"] = true, + ["DefaultExpiration"] = 365 + } + }); + + var boxProvider = new StorageProvider + { + Name = "Box.com", + Type = "Box", + Description = "Box.com Cloud Storage", + IsActive = false, + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }; + + boxProvider.SetConfiguration(new StorageProviderConfiguration + { + Container = "software-releases", + AdditionalSettings = new Dictionary + { + ["EnableSharedLinks"] = true, + ["DefaultPermission"] = "view" + } + }); + + var ftpProvider = new StorageProvider + { + Name = "FTP Server", + Type = "FTP", + Description = "Legacy FTP Server", + IsActive = false, + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }; + + ftpProvider.SetConfiguration(new StorageProviderConfiguration + { + Host = "ftp.company.com", + Port = 21, + UseSSL = false, + AdditionalSettings = new Dictionary + { + ["PassiveMode"] = true, + ["BinaryMode"] = true + } + }); + + _context.StorageProviders.AddRange(s3Provider, boxProvider, ftpProvider); + + _logger.LogInformation("Added {Count} storage providers", 3); + } + } + + private async Task SeedHelpCenterProvidersAsync() + { + if (!_context.HelpCenterProviders.Any()) + { + _logger.LogInformation("Seeding default help center providers..."); + + var zendeskProvider = new HelpCenterProvider + { + Name = "Zendesk", + Type = "Zendesk", + Description = "Zendesk Help Center Integration", + IsActive = true, + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }; + + zendeskProvider.SetConfiguration(new HelpCenterProviderConfiguration + { + Subdomain = "company", + ApiVersion = "v2", + DefaultLocale = "en-us", + DefaultTemplateVariables = new Dictionary + { + ["VERSION"] = "{{VERSION}}", + ["SWURL"] = "{{SWURL}}", + ["PDFURL"] = "{{PDFURL}}", + ["DATE"] = "{{DATE}}", + ["PROJECT"] = "{{PROJECT}}" + } + }); + + var salesforceProvider = new HelpCenterProvider + { + Name = "Salesforce Service", + Type = "Salesforce", + Description = "Salesforce Service Cloud Integration", + IsActive = false, + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }; + + salesforceProvider.SetConfiguration(new HelpCenterProviderConfiguration + { + LoginUrl = "https://login.salesforce.com", + ApiVersion = "v58.0", + DefaultLocale = "en-us" + }); + + _context.HelpCenterProviders.AddRange(zendeskProvider, salesforceProvider); + + _logger.LogInformation("Added {Count} help center providers", 2); + } + } + + public async Task SeedTestDataAsync() + { + _logger.LogInformation("Seeding test data..."); + + await SeedTestProjectsAsync(); + await SeedTestPackagesAsync(); + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Test data seeding completed"); + } + + private async Task SeedTestProjectsAsync() + { + if (!_context.Projects.Any()) + { + var projects = new[] + { + new Project + { + Name = "DataPRO LTS", + Description = "Long Term Support version of DataPRO", + CCNetProjectName = "DATAPRO_MAINT_4_00", + Status = "Active", + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }, + new Project + { + Name = "DataPRO Stable", + Description = "Stable release version of DataPRO", + CCNetProjectName = "DATAPRO_MAINT_4_04", + Status = "Active", + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + } + }; + + _context.Projects.AddRange(projects); + } + } + + private async Task SeedTestPackagesAsync() + { + if (!_context.Packages.Any() && _context.Projects.Any()) + { + var ltsProject = _context.Projects.First(p => p.CCNetProjectName == "DATAPRO_MAINT_4_00"); + var stableProject = _context.Projects.First(p => p.CCNetProjectName == "DATAPRO_MAINT_4_04"); + + var packages = new[] + { + new Package + { + Title = "DataPRO LTS Release", + Version = "4.0.1205", + Description = "Long term support release for enterprise customers", + ProjectId = ltsProject.Id, + Status = "Draft", + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + }, + new Package + { + Title = "DataPRO Stable Release", + Version = "4.4.305", + Description = "Latest stable release with new features", + ProjectId = stableProject.Id, + Status = "Draft", + CreatedBy = "System", + CreatedAt = DateTime.UtcNow + } + }; + + _context.Packages.AddRange(packages); + } + } + } +} \ No newline at end of file diff --git a/src/Database/Services/PasswordHasher.cs b/src/Database/Services/PasswordHasher.cs new file mode 100644 index 0000000..e019f3d --- /dev/null +++ b/src/Database/Services/PasswordHasher.cs @@ -0,0 +1,38 @@ +using BCrypt.Net; + +namespace Database.Services +{ + public interface IPasswordHasher + { + string HashPassword(string password); + bool VerifyPassword(string password, string hashedPassword); + } + + public class PasswordHasher : IPasswordHasher + { + private const int WorkFactor = 12; + + public string HashPassword(string password) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentException("Password cannot be null or empty", nameof(password)); + + return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor); + } + + public bool VerifyPassword(string password, string hashedPassword) + { + if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashedPassword)) + return false; + + try + { + return BCrypt.Net.BCrypt.Verify(password, hashedPassword); + } + catch + { + return false; + } + } + } +} \ No newline at end of file diff --git a/src/Database/appsettings.Development.json b/src/Database/appsettings.Development.json new file mode 100644 index 0000000..9501aec --- /dev/null +++ b/src/Database/appsettings.Development.json @@ -0,0 +1,16 @@ +{ + "Database": { + "EnableSensitiveDataLogging": true, + "EnableDetailedErrors": true, + "SeedTestData": true, + "AutoMigrate": true + }, + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Debug" + } + } +} \ No newline at end of file diff --git a/src/Database/appsettings.Production.json b/src/Database/appsettings.Production.json new file mode 100644 index 0000000..24fd340 --- /dev/null +++ b/src/Database/appsettings.Production.json @@ -0,0 +1,28 @@ +{ + "Database": { + "EnableSensitiveDataLogging": false, + "EnableDetailedErrors": false, + "CommandTimeout": 60, + "SeedTestData": false, + "AutoMigrate": false, + "Environment": "Production", + "Pooling": { + "MinPoolSize": 10, + "MaxPoolSize": 200, + "ConnectionIdleLifetime": 600, + "ConnectionPruningInterval": 30 + }, + "Retry": { + "EnableRetryOnFailure": true, + "MaxRetryCount": 3, + "MaxRetryDelay": 60 + } + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Error" + } + } +} \ No newline at end of file diff --git a/src/Database/appsettings.json b/src/Database/appsettings.json new file mode 100644 index 0000000..3c1d421 --- /dev/null +++ b/src/Database/appsettings.json @@ -0,0 +1,31 @@ +{ + "Database": { + "ConnectionString": "Host=localhost;Port=5432;Database=software_release_management;Username=postgres;Password=postgres", + "EnableSensitiveDataLogging": false, + "EnableDetailedErrors": false, + "CommandTimeout": 30, + "AutoMigrate": false, + "SeedTestData": false, + "Environment": "Development", + "Pooling": { + "MinPoolSize": 5, + "MaxPoolSize": 100, + "ConnectionIdleLifetime": 300, + "ConnectionPruningInterval": 10 + }, + "Retry": { + "EnableRetryOnFailure": true, + "MaxRetryCount": 5, + "MaxRetryDelay": 30, + "ErrorNumbersToAdd": [] + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} \ No newline at end of file