20 Commits

Author SHA1 Message Date
feef8cbafd feat(copy): wrote some basic copy for the statis pages, landing, prices, products
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-04 22:08:42 -04:00
b7379cf823 feat: just getting better and better
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-04 21:34:38 -04:00
664eb07201 Polish workspace organization selector
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-04 17:44:39 -04:00
58c1301054 refactor: remove organization slug 2026-05-04 17:41:50 -04:00
552f4f1f21 fix: collapse sidebar by default on small screens 2026-05-04 16:40:43 -04:00
8f4b95f311 feat: add organization settings UI 2026-05-04 16:33:34 -04:00
4fba72e99c feat: prerender public site pages 2026-05-04 16:29:50 -04:00
55d8acef4c Refine content approval workflow rail 2026-05-04 16:20:32 -04:00
7d3f495472 feat: add organization domain foundation 2026-05-04 16:15:53 -04:00
802668fb0b feat: add public site pages and social login 2026-05-04 16:13:57 -04:00
cd6f402d9e docs: define organization account model 2026-05-04 15:45:12 -04:00
9bdef978bd refactor: align main layout shell 2026-05-04 14:46:13 -04:00
2d472892d6 Merge branch 'approval-workflow-docs' into HEAD
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
# Conflicts:
#	frontend/src/api/schema.d.ts
#	frontend/src/features/content/views/ContentItemDetailView.vue
#	frontend/src/features/workspaces/views/DashboardView.vue
#	shared/openapi/openapi.json
2026-05-01 15:58:04 -04:00
884ca4b96d chore: add missing multi-level editor for approval workflow, rename projects to campaings. 2026-05-01 15:50:02 -04:00
df0409d7f6 wip 2026-05-01 14:23:37 -04:00
5077f557f4 docs: redefine approval workflow 2026-05-01 00:58:47 -04:00
1722d65d22 chore(doc): remove unused edit-workspace-settings task
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 16:08:55 -04:00
14023e65d5 docs: remove platform-scaffold feature and tasks.
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 15:56:07 -04:00
237b1a4242 docs: adds workspace-invites feature and tasks
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 15:46:06 -04:00
ace0279bd0 fix(workspace-invite): inconsistence in roles names 2026-04-30 15:45:32 -04:00
235 changed files with 13907 additions and 6826 deletions

View File

@@ -70,6 +70,7 @@ Update OpenAPI:
## Current Domain Modules
- `Identity`: authentication, refresh tokens, email verification, password reset, social login.
- `Organizations`: SaaS account ownership, billing/subscription boundary, organization membership, connectors, data mappings, and owned workspaces.
- `Workspaces`: workspace membership, workspace settings, access scoping.
- `Clients`: client records and primary contacts tied to workspaces.
- `Projects`: project pipeline and client/project relationships.

View File

@@ -1,6 +1,6 @@
# Socialize
Socialize is a workspace-based workflow application for social media content review, revision, approval, and publication readiness.
Socialize is an organization-owned, workspace-based workflow application for social media content review, revision, approval, and publication readiness.
It is not a public social network. The product is for internal teams, providers, and client approvers coordinating content work before publication.

View File

@@ -8,7 +8,8 @@ using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Projects.Data;
using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Data;
@@ -17,17 +18,21 @@ public class AppDbContext(
DbContextOptions<AppDbContext> options)
: IdentityDbContext<User, Role, Guid>(options)
{
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
public DbSet<Client> Clients => Set<Client>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
public DbSet<ContentItemRevision> ContentItemRevisions => Set<ContentItemRevision>();
public DbSet<Asset> Assets => Set<Asset>();
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
public DbSet<Comment> Comments => Set<Comment>();
public DbSet<ApprovalWorkflowInstance> ApprovalWorkflowInstances => Set<ApprovalWorkflowInstance>();
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
public DbSet<WorkspaceApprovalStepConfiguration> WorkspaceApprovalStepConfigurations => Set<WorkspaceApprovalStepConfiguration>();
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
@@ -39,9 +44,10 @@ public class AppDbContext(
{
base.OnModelCreating(builder);
builder.ConfigureOrganizationsModule();
builder.ConfigureWorkspacesModule();
builder.ConfigureClientsModule();
builder.ConfigureProjectsModule();
builder.ConfigureCampaignsModule();
builder.ConfigureContentItemsModule();
builder.ConfigureAssetsModule();
builder.ConfigureCommentsModule();

View File

@@ -10,7 +10,9 @@ using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Projects.Data;
using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
@@ -19,11 +21,12 @@ namespace Socialize.Api.Infrastructure.Development;
public static class DevelopmentSeedExtensions
{
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
private static readonly Guid ScopedProjectId = Guid.Parse("33333333-3333-3333-3333-333333333333");
private static readonly Guid HiddenProjectId = Guid.Parse("33333333-3333-3333-3333-444444444444");
private static readonly Guid ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333");
private static readonly Guid HiddenCampaignId = Guid.Parse("33333333-3333-3333-3333-444444444444");
private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
@@ -99,7 +102,7 @@ public static class DevelopmentSeedExtensions
[
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()),
new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()),
new Claim(KnownClaims.CampaignScope, ScopedCampaignId.ToString()),
]);
User dev = await EnsureUserAsync(
@@ -117,6 +120,12 @@ public static class DevelopmentSeedExtensions
[
]);
await EnsureOrganizationDataAsync(
manager.Id,
dev.Id,
dbContext,
cancellationToken);
await EnsureWorkspaceDataAsync(
manager.Id,
clientUser.Id,
@@ -200,7 +209,7 @@ public static class DevelopmentSeedExtensions
IList<Claim> existingClaims = await userManager.GetClaimsAsync(user);
List<Claim> managedClaims = existingClaims
.Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.ProjectScope or KnownClaims.Persona)
.Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.CampaignScope or KnownClaims.Persona)
.ToList();
foreach (Claim claim in managedClaims)
@@ -224,6 +233,75 @@ public static class DevelopmentSeedExtensions
return user;
}
private static async Task EnsureOrganizationDataAsync(
Guid managerUserId,
Guid developerUserId,
AppDbContext dbContext,
CancellationToken cancellationToken)
{
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == OrganizationId, cancellationToken);
if (organization is null)
{
organization = new Organization
{
Id = OrganizationId,
Name = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Organizations.Add(organization);
}
organization.Name = "Northstar Collective";
organization.OwnerUserId = managerUserId;
await UpsertOrganizationMembershipAsync(
dbContext,
Guid.Parse("99999999-9999-9999-9999-000000000001"),
OrganizationId,
managerUserId,
OrganizationRoles.Owner,
cancellationToken);
await UpsertOrganizationMembershipAsync(
dbContext,
Guid.Parse("99999999-9999-9999-9999-000000000002"),
OrganizationId,
developerUserId,
OrganizationRoles.Admin,
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertOrganizationMembershipAsync(
AppDbContext dbContext,
Guid membershipId,
Guid organizationId,
Guid userId,
string role,
CancellationToken cancellationToken)
{
OrganizationMembership? membership = await dbContext.OrganizationMemberships
.SingleOrDefaultAsync(
candidate => candidate.OrganizationId == organizationId && candidate.UserId == userId,
cancellationToken);
if (membership is null)
{
membership = new OrganizationMembership
{
Id = membershipId,
OrganizationId = organizationId,
UserId = userId,
Role = role,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.OrganizationMemberships.Add(membership);
}
membership.Role = role;
}
private static async Task EnsureWorkspaceDataAsync(
Guid managerUserId,
Guid clientUserId,
@@ -239,7 +317,6 @@ public static class DevelopmentSeedExtensions
{
Id = WorkspaceId,
Name = string.Empty,
Slug = string.Empty,
TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
@@ -247,7 +324,7 @@ public static class DevelopmentSeedExtensions
}
workspace.Name = "Northstar Studio";
workspace.Slug = "northstar-studio";
workspace.OrganizationId = OrganizationId;
workspace.OwnerUserId = managerUserId;
workspace.TimeZone = "America/Montreal";
await dbContext.SaveChangesAsync(cancellationToken);
@@ -273,9 +350,9 @@ public static class DevelopmentSeedExtensions
WorkspaceId,
cancellationToken);
await UpsertProjectAsync(
await UpsertCampaignAsync(
dbContext,
ScopedProjectId,
ScopedCampaignId,
WorkspaceId,
ScopedClientId,
"Spring Launch",
@@ -285,9 +362,9 @@ public static class DevelopmentSeedExtensions
"Cross-channel launch campaign for the spring offer.",
"Coordinate creative approvals before the final week.",
cancellationToken);
await UpsertProjectAsync(
await UpsertCampaignAsync(
dbContext,
HiddenProjectId,
HiddenCampaignId,
WorkspaceId,
HiddenClientId,
"Summer Retention",
@@ -303,11 +380,11 @@ public static class DevelopmentSeedExtensions
ScopedContentItemId,
WorkspaceId,
ScopedClientId,
ScopedProjectId,
ScopedCampaignId,
"Spring launch hero video",
"Fresh seasonal menu launch across Instagram and TikTok.",
"Instagram Reel, TikTok",
"In client review",
"In approval",
DateTimeOffset.UtcNow.AddDays(3),
"v3",
3,
@@ -317,7 +394,7 @@ public static class DevelopmentSeedExtensions
HiddenContentItemId,
WorkspaceId,
HiddenClientId,
HiddenProjectId,
HiddenCampaignId,
"Bakery loyalty carousel",
"Reward regular customers with a four-card retention carousel.",
"Instagram Carousel",
@@ -491,7 +568,7 @@ public static class DevelopmentSeedExtensions
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertProjectAsync(
private static async Task UpsertCampaignAsync(
AppDbContext dbContext,
Guid id,
Guid workspaceId,
@@ -504,26 +581,26 @@ public static class DevelopmentSeedExtensions
string? notes,
CancellationToken cancellationToken)
{
Project? project = await dbContext.Projects.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (project is null)
Campaign? campaign = await dbContext.Campaigns.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (campaign is null)
{
project = new Project
campaign = new Campaign
{
Id = id,
Name = string.Empty,
Status = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Projects.Add(project);
dbContext.Campaigns.Add(campaign);
}
project.WorkspaceId = workspaceId;
project.ClientId = clientId;
project.Name = name;
project.Description = description;
project.Notes = notes;
project.Status = status;
project.StartDate = startDate;
project.EndDate = endDate;
campaign.WorkspaceId = workspaceId;
campaign.ClientId = clientId;
campaign.Name = name;
campaign.Description = description;
campaign.Notes = notes;
campaign.Status = status;
campaign.StartDate = startDate;
campaign.EndDate = endDate;
await dbContext.SaveChangesAsync(cancellationToken);
}
@@ -532,7 +609,7 @@ public static class DevelopmentSeedExtensions
Guid id,
Guid workspaceId,
Guid clientId,
Guid projectId,
Guid campaignId,
string title,
string publicationMessage,
string publicationTargets,
@@ -559,7 +636,7 @@ public static class DevelopmentSeedExtensions
}
item.WorkspaceId = workspaceId;
item.ClientId = clientId;
item.ProjectId = projectId;
item.CampaignId = campaignId;
item.Title = title;
item.PublicationMessage = publicationMessage;
item.PublicationTargets = publicationTargets;

View File

@@ -1,9 +1,11 @@
using System.Security.Claims;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Infrastructure.Security;
public sealed class AccessScopeService
public sealed class AccessScopeService(
OrganizationAccessService organizationAccessService)
{
public bool IsManager(ClaimsPrincipal user)
{
@@ -36,21 +38,140 @@ public sealed class AccessScopeService
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId));
}
public bool CanAccessProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
public bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user)
|| (CanAccessClient(user, workspaceId, clientId) && user.GetProjectScopeIds().Contains(projectId));
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId));
}
public bool CanContributeToProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
public bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user) || (IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId));
return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId));
}
public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user)
|| IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId)
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
}
public Task<IReadOnlyCollection<Guid>> GetAccessibleWorkspaceIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
return organizationAccessService.GetAccessibleWorkspaceIdsAsync(user, ct);
}
public async Task<bool> CanAccessWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return CanAccessWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct);
}
public async Task<bool> CanManageWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct);
}
public async Task<bool> CanCreateWorkspaceAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasOrganizationPermissionAsync(
user,
organizationId,
OrganizationPermissions.CreateWorkspaces,
ct);
}
public async Task<bool> CanAccessClientAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct))
{
return true;
}
return user.GetWorkspaceScopeIds().Contains(workspaceId) && user.GetClientScopeIds().Contains(clientId);
}
public async Task<bool> CanAccessCampaignAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct))
{
return true;
}
return await CanAccessClientAsync(user, workspaceId, clientId, ct) &&
user.GetCampaignScopeIds().Contains(campaignId);
}
public async Task<bool> CanContributeToCampaignAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct)
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct);
}
public async Task<bool> CanReviewContentAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct)
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
}
}

View File

@@ -23,9 +23,9 @@ public static class ClaimsPrincipalExtensions
return claims.GetScopeIds(KnownClaims.ClientScope);
}
public static IReadOnlyCollection<Guid> GetProjectScopeIds(this ClaimsPrincipal claims)
public static IReadOnlyCollection<Guid> GetCampaignScopeIds(this ClaimsPrincipal claims)
{
return claims.GetScopeIds(KnownClaims.ProjectScope);
return claims.GetScopeIds(KnownClaims.CampaignScope);
}
public static string? GetPersona(this ClaimsPrincipal claims)

View File

@@ -6,6 +6,6 @@ public static class KnownClaims
public const string PortraitUrl = "portraitUrl";
public const string WorkspaceScope = "workspace";
public const string ClientScope = "client";
public const string ProjectScope = "project";
public const string CampaignScope = "campaign";
public const string Persona = "persona";
}

View File

@@ -1,942 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Socialize.Api.Data;
#nullable disable
namespace Socialize.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260423061407_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApprovalRequestId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("DecidedByEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("DecidedByName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("DecidedByUserId")
.HasColumnType("uuid");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("ApprovalRequestId");
b.ToTable("ApprovalDecisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccessToken")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DueAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RequestedByUserId")
.HasColumnType("uuid");
b.Property<string>("ReviewerEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReviewerName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("SentAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Stage")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ReviewerEmail");
b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<int>("CurrentRevisionNumber")
.HasColumnType("integer");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveFileId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveLink")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.ToTable("Assets", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("RevisionNumber")
.HasColumnType("integer");
b.Property<string>("SourceReference")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("AssetId", "RevisionNumber")
.IsUnique();
b.ToTable("AssetRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PrimaryContactEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PrimaryContactName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PrimaryContactPortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Name")
.IsUnique();
b.ToTable("Clients", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AuthorDisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("AuthorEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("AuthorUserId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("ParentCommentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ParentCommentId");
b.HasIndex("WorkspaceId");
b.ToTable("Comments", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CurrentRevisionLabel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("CurrentRevisionNumber")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Hashtags")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("PublicationTargets")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("ProjectId");
b.HasIndex("WorkspaceId");
b.ToTable("ContentItems", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ChangeSummary")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Hashtags")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("PublicationTargets")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RevisionLabel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("RevisionNumber")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ContentItemId", "RevisionNumber")
.IsUnique();
b.ToTable("ContentItemRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("Address")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Alias")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("BirthDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("FacebookId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Firstname")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Lastname")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RefreshToken")
.HasMaxLength(44)
.HasColumnType("character varying(44)");
b.Property<DateTime>("RefreshTokenExpiryTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("MetadataJson")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset?>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RecipientEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("RecipientUserId")
.HasColumnType("uuid");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("CreatedAt");
b.HasIndex("RecipientUserId");
b.HasIndex("WorkspaceId");
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Projects", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("InvitedByUserId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Email", "Status");
b.ToTable("WorkspaceInvites", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,33 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Socialize.Api.Data;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260430054500_AddWorkspaceLogo")]
public partial class AddWorkspaceLogo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LogoUrl",
table: "Workspaces",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LogoUrl",
table: "Workspaces");
}
}
}

View File

@@ -1,116 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackReports",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Description = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
ReporterUserId = table.Column<Guid>(type: "uuid", nullable: false),
ReporterDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ReporterEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
SubmittedPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
BrowserUserAgent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ViewportWidth = table.Column<int>(type: "integer", nullable: true),
ViewportHeight = table.Column<int>(type: "integer", nullable: true),
AppVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
WorkspaceName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ClientId = table.Column<Guid>(type: "uuid", nullable: true),
ClientName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
ProjectName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
ContentItemTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
LastActivityAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CancelledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CancelledByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CancellationReason = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackReports", x => x.Id);
});
migrationBuilder.CreateTable(
name: "FeedbackTags",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackTags", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackTags_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_LastActivityAt",
table: "FeedbackReports",
column: "LastActivityAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_ReporterUserId",
table: "FeedbackReports",
column: "ReporterUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_Status",
table: "FeedbackReports",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_Type",
table: "FeedbackReports",
column: "Type");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_WorkspaceId",
table: "FeedbackReports",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackTags_FeedbackReportId_NormalizedName",
table: "FeedbackTags",
columns: new[] { "FeedbackReportId", "NormalizedName" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FeedbackTags_NormalizedName",
table: "FeedbackTags",
column: "NormalizedName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackTags");
migrationBuilder.DropTable(
name: "FeedbackReports");
}
}
}

View File

@@ -1,52 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackScreenshots : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackScreenshots",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
BlobContainerName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
BlobName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackScreenshots", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackScreenshots_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackScreenshots_FeedbackReportId",
table: "FeedbackScreenshots",
column: "FeedbackReportId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackScreenshots");
}
}
}

View File

@@ -1,105 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackCommentsActivity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackActivityEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
ActorUserId = table.Column<Guid>(type: "uuid", nullable: false),
ActorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ActorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ActivityType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
FromValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
ToValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
Note = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackActivityEntries", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackActivityEntries_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FeedbackComments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorUserId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorRole = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Body = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackComments", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackComments_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_ActorUserId",
table: "FeedbackActivityEntries",
column: "ActorUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_CreatedAt",
table: "FeedbackActivityEntries",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_FeedbackReportId",
table: "FeedbackActivityEntries",
column: "FeedbackReportId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_AuthorUserId",
table: "FeedbackComments",
column: "AuthorUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_CreatedAt",
table: "FeedbackComments",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_FeedbackReportId",
table: "FeedbackComments",
column: "FeedbackReportId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackActivityEntries");
migrationBuilder.DropTable(
name: "FeedbackComments");
}
}
}

View File

@@ -12,8 +12,8 @@ using Socialize.Api.Data;
namespace Socialize.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260430171959_AddFeedbackCommentsActivity")]
partial class AddFeedbackCommentsActivity
[Migration("20260505013232_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -219,6 +219,23 @@ namespace Socialize.Api.Migrations
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("WorkflowInstanceId")
.HasColumnType("uuid");
b.Property<int?>("WorkflowStepRequiredApproverCount")
.HasColumnType("integer");
b.Property<int?>("WorkflowStepSortOrder")
.HasColumnType("integer");
b.Property<string>("WorkflowStepTargetType")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("WorkflowStepTargetValue")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
@@ -228,11 +245,103 @@ namespace Socialize.Api.Migrations
b.HasIndex("ReviewerEmail");
b.HasIndex("WorkflowInstanceId");
b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.HasIndex("ContentItemId", "State")
.IsUnique()
.HasFilter("\"State\" = 'Pending'");
b.ToTable("ApprovalWorkflowInstances", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("RequiredApproverCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetValue")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "SortOrder")
.IsUnique();
b.ToTable("WorkspaceApprovalStepConfigurations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
{
b.Property<Guid>("Id")
@@ -332,6 +441,59 @@ namespace Socialize.Api.Migrations
b.ToTable("AssetRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Campaigns", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{
b.Property<Guid>("Id")
@@ -443,6 +605,9 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
@@ -466,9 +631,6 @@ namespace Socialize.Api.Migrations
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
@@ -494,9 +656,9 @@ namespace Socialize.Api.Migrations
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("CampaignId");
b.HasIndex("ProjectId");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
@@ -678,6 +840,13 @@ namespace Socialize.Api.Migrations
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid?>("CampaignId")
.HasColumnType("uuid");
b.Property<string>("CampaignName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("CancellationReason")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
@@ -715,13 +884,6 @@ namespace Socialize.Api.Migrations
b.Property<DateTimeOffset>("LastActivityAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("ProjectName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReporterDisplayName")
.IsRequired()
.HasMaxLength(256)
@@ -1044,57 +1206,64 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b =>
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("OwnerUserId");
b.HasIndex("WorkspaceId");
b.ToTable("Organizations", (string)null);
});
b.HasIndex("ClientId", "Name")
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("UserId");
b.HasIndex("OrganizationId", "UserId")
.IsUnique();
b.ToTable("Projects", (string)null);
b.ToTable("OrganizationMemberships", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
@@ -1103,11 +1272,23 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("Required");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("LockContentAfterApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -1117,13 +1298,21 @@ namespace Socialize.Api.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("SchedulePostsAutomaticallyOnApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("SendAutomaticApprovalReminders")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("TimeZone")
.IsRequired()
@@ -1132,10 +1321,9 @@ namespace Socialize.Api.Migrations
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("OwnerUserId");
b.ToTable("Workspaces", (string)null);
});
@@ -1276,6 +1464,24 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{
b.Navigation("ActivityEntries");

View File

@@ -37,6 +37,11 @@ namespace Socialize.Api.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
WorkflowInstanceId = table.Column<Guid>(type: "uuid", nullable: true),
WorkflowStepSortOrder = table.Column<int>(type: "integer", nullable: true),
WorkflowStepTargetType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
WorkflowStepTargetValue = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
WorkflowStepRequiredApproverCount = table.Column<int>(type: "integer", nullable: true),
Stage = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ReviewerName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ReviewerEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
@@ -52,6 +57,23 @@ namespace Socialize.Api.Migrations
table.PrimaryKey("PK_ApprovalRequests", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ApprovalWorkflowInstances",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
State = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ApprovalMode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalWorkflowInstances", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
@@ -140,6 +162,26 @@ namespace Socialize.Api.Migrations
table.PrimaryKey("PK_Assets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Campaigns",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Notes = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Campaigns", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Clients",
columns: table => new
@@ -208,7 +250,7 @@ namespace Socialize.Api.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
CampaignId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
PublicationMessage = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
PublicationTargets = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
@@ -224,6 +266,41 @@ namespace Socialize.Api.Migrations
table.PrimaryKey("PK_ContentItems", x => x.Id);
});
migrationBuilder.CreateTable(
name: "FeedbackReports",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Description = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
ReporterUserId = table.Column<Guid>(type: "uuid", nullable: false),
ReporterDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ReporterEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
SubmittedPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
BrowserUserAgent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ViewportWidth = table.Column<int>(type: "integer", nullable: true),
ViewportHeight = table.Column<int>(type: "integer", nullable: true),
AppVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
WorkspaceName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ClientId = table.Column<Guid>(type: "uuid", nullable: true),
ClientName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
CampaignId = table.Column<Guid>(type: "uuid", nullable: true),
CampaignName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
ContentItemTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
LastActivityAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CancelledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CancelledByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CancellationReason = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackReports", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NotificationEvents",
columns: table => new
@@ -247,23 +324,35 @@ namespace Socialize.Api.Migrations
});
migrationBuilder.CreateTable(
name: "Projects",
name: "Organizations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Notes = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
table.PrimaryKey("PK_Organizations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "WorkspaceApprovalStepConfigurations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
TargetType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
TargetValue = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
RequiredApproverCount = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_WorkspaceApprovalStepConfigurations", x => x.Id);
});
migrationBuilder.CreateTable(
@@ -283,22 +372,6 @@ namespace Socialize.Api.Migrations
table.PrimaryKey("PK_WorkspaceInvites", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Workspaces",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Slug = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
TimeZone = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Workspaces", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
@@ -405,6 +478,148 @@ namespace Socialize.Api.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FeedbackActivityEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
ActorUserId = table.Column<Guid>(type: "uuid", nullable: false),
ActorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ActorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ActivityType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
FromValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
ToValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
Note = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackActivityEntries", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackActivityEntries_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FeedbackComments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorUserId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorRole = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Body = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackComments", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackComments_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FeedbackScreenshots",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
BlobContainerName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
BlobName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackScreenshots", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackScreenshots_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FeedbackTags",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackTags", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackTags_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OrganizationMemberships",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_OrganizationMemberships", x => x.Id);
table.ForeignKey(
name: "FK_OrganizationMemberships_Organizations_OrganizationId",
column: x => x.OrganizationId,
principalTable: "Organizations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Workspaces",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
LogoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
TimeZone = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
ApprovalMode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "Required"),
SchedulePostsAutomaticallyOnApproval = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
LockContentAfterApproval = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SendAutomaticApprovalReminders = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Workspaces", x => x.Id);
table.ForeignKey(
name: "FK_Workspaces_Organizations_OrganizationId",
column: x => x.OrganizationId,
principalTable: "Organizations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_ApprovalDecisions_ApprovalRequestId",
table: "ApprovalDecisions",
@@ -420,11 +635,33 @@ namespace Socialize.Api.Migrations
table: "ApprovalRequests",
column: "ReviewerEmail");
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_WorkflowInstanceId",
table: "ApprovalRequests",
column: "WorkflowInstanceId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_WorkspaceId",
table: "ApprovalRequests",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_ContentItemId",
table: "ApprovalWorkflowInstances",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_ContentItemId_State",
table: "ApprovalWorkflowInstances",
columns: new[] { "ContentItemId", "State" },
unique: true,
filter: "\"State\" = 'Pending'");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_WorkspaceId",
table: "ApprovalWorkflowInstances",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
@@ -483,6 +720,22 @@ namespace Socialize.Api.Migrations
table: "Assets",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Campaigns_ClientId",
table: "Campaigns",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_Campaigns_ClientId_Name",
table: "Campaigns",
columns: new[] { "ClientId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Campaigns_WorkspaceId",
table: "Campaigns",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Clients_WorkspaceId",
table: "Clients",
@@ -520,21 +773,93 @@ namespace Socialize.Api.Migrations
columns: new[] { "ContentItemId", "RevisionNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ContentItems_CampaignId",
table: "ContentItems",
column: "CampaignId");
migrationBuilder.CreateIndex(
name: "IX_ContentItems_ClientId",
table: "ContentItems",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_ContentItems_ProjectId",
table: "ContentItems",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_ContentItems_WorkspaceId",
table: "ContentItems",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_ActorUserId",
table: "FeedbackActivityEntries",
column: "ActorUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_CreatedAt",
table: "FeedbackActivityEntries",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_FeedbackReportId",
table: "FeedbackActivityEntries",
column: "FeedbackReportId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_AuthorUserId",
table: "FeedbackComments",
column: "AuthorUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_CreatedAt",
table: "FeedbackComments",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_FeedbackReportId",
table: "FeedbackComments",
column: "FeedbackReportId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_LastActivityAt",
table: "FeedbackReports",
column: "LastActivityAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_ReporterUserId",
table: "FeedbackReports",
column: "ReporterUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_Status",
table: "FeedbackReports",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_Type",
table: "FeedbackReports",
column: "Type");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_WorkspaceId",
table: "FeedbackReports",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackScreenshots_FeedbackReportId",
table: "FeedbackScreenshots",
column: "FeedbackReportId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FeedbackTags_FeedbackReportId_NormalizedName",
table: "FeedbackTags",
columns: new[] { "FeedbackReportId", "NormalizedName" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FeedbackTags_NormalizedName",
table: "FeedbackTags",
column: "NormalizedName");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_ContentItemId",
table: "NotificationEvents",
@@ -556,21 +881,37 @@ namespace Socialize.Api.Migrations
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Projects_ClientId",
table: "Projects",
column: "ClientId");
name: "IX_OrganizationMemberships_OrganizationId",
table: "OrganizationMemberships",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_Projects_ClientId_Name",
table: "Projects",
columns: new[] { "ClientId", "Name" },
name: "IX_OrganizationMemberships_OrganizationId_UserId",
table: "OrganizationMemberships",
columns: new[] { "OrganizationId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Projects_WorkspaceId",
table: "Projects",
name: "IX_OrganizationMemberships_UserId",
table: "OrganizationMemberships",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Organizations_OwnerUserId",
table: "Organizations",
column: "OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId",
table: "WorkspaceApprovalStepConfigurations",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId_SortOrder",
table: "WorkspaceApprovalStepConfigurations",
columns: new[] { "WorkspaceId", "SortOrder" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_WorkspaceInvites_WorkspaceId",
table: "WorkspaceInvites",
@@ -581,16 +922,15 @@ namespace Socialize.Api.Migrations
table: "WorkspaceInvites",
columns: new[] { "WorkspaceId", "Email", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OrganizationId",
table: "Workspaces",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OwnerUserId",
table: "Workspaces",
column: "OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_Workspaces_Slug",
table: "Workspaces",
column: "Slug",
unique: true);
}
/// <inheritdoc />
@@ -602,6 +942,9 @@ namespace Socialize.Api.Migrations
migrationBuilder.DropTable(
name: "ApprovalRequests");
migrationBuilder.DropTable(
name: "ApprovalWorkflowInstances");
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
@@ -623,6 +966,9 @@ namespace Socialize.Api.Migrations
migrationBuilder.DropTable(
name: "Assets");
migrationBuilder.DropTable(
name: "Campaigns");
migrationBuilder.DropTable(
name: "Clients");
@@ -635,11 +981,26 @@ namespace Socialize.Api.Migrations
migrationBuilder.DropTable(
name: "ContentItems");
migrationBuilder.DropTable(
name: "FeedbackActivityEntries");
migrationBuilder.DropTable(
name: "FeedbackComments");
migrationBuilder.DropTable(
name: "FeedbackScreenshots");
migrationBuilder.DropTable(
name: "FeedbackTags");
migrationBuilder.DropTable(
name: "NotificationEvents");
migrationBuilder.DropTable(
name: "Projects");
name: "OrganizationMemberships");
migrationBuilder.DropTable(
name: "WorkspaceApprovalStepConfigurations");
migrationBuilder.DropTable(
name: "WorkspaceInvites");
@@ -652,6 +1013,12 @@ namespace Socialize.Api.Migrations
migrationBuilder.DropTable(
name: "AspNetUsers");
migrationBuilder.DropTable(
name: "FeedbackReports");
migrationBuilder.DropTable(
name: "Organizations");
}
}
}

View File

@@ -216,6 +216,23 @@ namespace Socialize.Api.Migrations
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("WorkflowInstanceId")
.HasColumnType("uuid");
b.Property<int?>("WorkflowStepRequiredApproverCount")
.HasColumnType("integer");
b.Property<int?>("WorkflowStepSortOrder")
.HasColumnType("integer");
b.Property<string>("WorkflowStepTargetType")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("WorkflowStepTargetValue")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
@@ -225,11 +242,103 @@ namespace Socialize.Api.Migrations
b.HasIndex("ReviewerEmail");
b.HasIndex("WorkflowInstanceId");
b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.HasIndex("ContentItemId", "State")
.IsUnique()
.HasFilter("\"State\" = 'Pending'");
b.ToTable("ApprovalWorkflowInstances", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("RequiredApproverCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetValue")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "SortOrder")
.IsUnique();
b.ToTable("WorkspaceApprovalStepConfigurations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
{
b.Property<Guid>("Id")
@@ -329,6 +438,59 @@ namespace Socialize.Api.Migrations
b.ToTable("AssetRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Campaigns", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{
b.Property<Guid>("Id")
@@ -440,6 +602,9 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
@@ -463,9 +628,6 @@ namespace Socialize.Api.Migrations
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
@@ -491,9 +653,9 @@ namespace Socialize.Api.Migrations
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("CampaignId");
b.HasIndex("ProjectId");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
@@ -675,6 +837,13 @@ namespace Socialize.Api.Migrations
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid?>("CampaignId")
.HasColumnType("uuid");
b.Property<string>("CampaignName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("CancellationReason")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
@@ -712,13 +881,6 @@ namespace Socialize.Api.Migrations
b.Property<DateTimeOffset>("LastActivityAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("ProjectName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReporterDisplayName")
.IsRequired()
.HasMaxLength(256)
@@ -1041,57 +1203,64 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b =>
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("OwnerUserId");
b.HasIndex("WorkspaceId");
b.ToTable("Organizations", (string)null);
});
b.HasIndex("ClientId", "Name")
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("UserId");
b.HasIndex("OrganizationId", "UserId")
.IsUnique();
b.ToTable("Projects", (string)null);
b.ToTable("OrganizationMemberships", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
@@ -1100,11 +1269,23 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("Required");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("LockContentAfterApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -1114,13 +1295,21 @@ namespace Socialize.Api.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("SchedulePostsAutomaticallyOnApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("SendAutomaticApprovalReminders")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("TimeZone")
.IsRequired()
@@ -1129,10 +1318,9 @@ namespace Socialize.Api.Migrations
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("OwnerUserId");
b.ToTable("Workspaces", (string)null);
});
@@ -1273,6 +1461,24 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{
b.Navigation("ActivityEntries");

View File

@@ -6,10 +6,28 @@ public static class ApprovalModelConfiguration
{
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<ApprovalWorkflowInstance>(workflowInstance =>
{
workflowInstance.ToTable("ApprovalWorkflowInstances");
workflowInstance.HasKey(x => x.Id);
workflowInstance.Property(x => x.State).HasMaxLength(64).IsRequired();
workflowInstance.Property(x => x.ApprovalMode).HasMaxLength(64).IsRequired();
workflowInstance.Property(x => x.StartedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workflowInstance.HasIndex(x => x.WorkspaceId);
workflowInstance.HasIndex(x => x.ContentItemId);
workflowInstance.HasIndex(x => new { x.ContentItemId, x.State })
.IsUnique()
.HasFilter("\"State\" = 'Pending'");
});
modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
{
approvalRequest.ToTable("ApprovalRequests");
approvalRequest.HasKey(x => x.Id);
approvalRequest.Property(x => x.WorkflowStepTargetType).HasMaxLength(32);
approvalRequest.Property(x => x.WorkflowStepTargetValue).HasMaxLength(128);
approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired();
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
@@ -20,6 +38,7 @@ public static class ApprovalModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalRequest.HasIndex(x => x.WorkspaceId);
approvalRequest.HasIndex(x => x.ContentItemId);
approvalRequest.HasIndex(x => x.WorkflowInstanceId);
approvalRequest.HasIndex(x => x.ReviewerEmail);
});
@@ -37,6 +56,21 @@ public static class ApprovalModelConfiguration
approvalDecision.HasIndex(x => x.ApprovalRequestId);
});
modelBuilder.Entity<WorkspaceApprovalStepConfiguration>(approvalStep =>
{
approvalStep.ToTable("WorkspaceApprovalStepConfigurations");
approvalStep.HasKey(x => x.Id);
approvalStep.Property(x => x.Name).HasMaxLength(128).IsRequired();
approvalStep.Property(x => x.TargetType).HasMaxLength(32).IsRequired();
approvalStep.Property(x => x.TargetValue).HasMaxLength(128).IsRequired();
approvalStep.Property(x => x.RequiredApproverCount).HasDefaultValue(1);
approvalStep.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalStep.HasIndex(x => x.WorkspaceId);
approvalStep.HasIndex(x => new { x.WorkspaceId, x.SortOrder }).IsUnique();
});
return modelBuilder;
}
}

View File

@@ -5,6 +5,11 @@ public class ApprovalRequest
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public Guid? WorkflowInstanceId { get; set; }
public int? WorkflowStepSortOrder { get; set; }
public string? WorkflowStepTargetType { get; set; }
public string? WorkflowStepTargetValue { get; set; }
public int? WorkflowStepRequiredApproverCount { get; set; }
public required string Stage { get; set; }
public required string ReviewerName { get; set; }
public required string ReviewerEmail { get; set; }

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.Approvals.Data;
public class ApprovalWorkflowInstance
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string State { get; set; }
public required string ApprovalMode { get; set; }
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace Socialize.Api.Modules.Approvals.Data;
public class WorkspaceApprovalStepConfiguration
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public int SortOrder { get; set; }
public required string TargetType { get; set; }
public required string TargetValue { get; set; }
public int RequiredApproverCount { get; set; } = 1;
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -1,4 +1,4 @@
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
namespace Socialize.Api.Modules.Approvals;
@@ -7,6 +7,8 @@ public static class DependencyInjection
public static WebApplicationBuilder AddApprovalsModule(
this WebApplicationBuilder builder)
{
builder.Services.AddScoped<ApprovalWorkflowRuntimeService>();
return builder;
}
}

View File

@@ -1,123 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Api.Modules.Approvals.Handlers;
public record CreateApprovalRequestRequest(
Guid WorkspaceId,
Guid ContentItemId,
string Stage,
string ReviewerName,
string ReviewerEmail,
DateTimeOffset? DueAt);
public class CreateApprovalRequestRequestValidator
: Validator<CreateApprovalRequestRequest>
{
public CreateApprovalRequestRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
}
}
public class CreateApprovalRequestHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
{
public override void Configure()
{
Post("/api/approvals");
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
{
var contentItem = await dbContext
.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
var approval = new ApprovalRequest()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
Stage = request.Stage.Trim(),
ReviewerName = request.ReviewerName.Trim(),
ReviewerEmail = request.ReviewerEmail.Trim(),
RequestedByUserId = User.GetUserId(),
DueAt = request.DueAt,
State = "Pending",
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
SentAt = DateTimeOffset.UtcNow,
};
dbContext.ApprovalRequests.Add(approval);
if (approval.Stage == "Internal")
{
contentItem.Status = "In internal review";
}
else if (approval.Stage == "Client")
{
contentItem.Status = "In client review";
}
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.requested",
"ApprovalRequest",
approval.Id,
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
null,
approval.ReviewerEmail,
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
ct);
ApprovalRequestDto dto = new(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
[]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -24,6 +24,11 @@ public record ApprovalRequestDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
Guid? WorkflowInstanceId,
int? WorkflowStepSortOrder,
string? WorkflowStepTargetType,
string? WorkflowStepTargetValue,
int? WorkflowStepRequiredApproverCount,
string Stage,
string ReviewerName,
string ReviewerEmail,
@@ -56,7 +61,7 @@ public class GetApprovalsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -65,6 +70,7 @@ public class GetApprovalsHandler(
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
.Where(approval => approval.ContentItemId == request.ContentItemId)
.OrderByDescending(approval => approval.SentAt)
.ThenBy(approval => approval.WorkflowStepSortOrder)
.ToListAsync(ct);
List<Guid> approvalIds = approvals
@@ -91,6 +97,11 @@ public class GetApprovalsHandler(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,

View File

@@ -4,13 +4,14 @@ using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest(
string Decision,
string? Comment,
string? ReviewerName,
string? ReviewerEmail);
@@ -19,8 +20,10 @@ public class SubmitApprovalDecisionRequestValidator
{
public SubmitApprovalDecisionRequestValidator()
{
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
RuleFor(x => x.Comment).MaximumLength(2048);
RuleFor(x => x.Decision)
.NotEmpty()
.Equal("Approved")
.WithMessage("Only approved decisions are supported.");
RuleFor(x => x.ReviewerName).MaximumLength(256);
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
}
@@ -29,6 +32,7 @@ public class SubmitApprovalDecisionRequestValidator
public class SubmitApprovalDecisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{
@@ -58,12 +62,19 @@ public class SubmitApprovalDecisionHandler(
}
if (User?.Identity?.IsAuthenticated == true &&
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
}
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true
? User.GetAlias() ?? User.GetName()
@@ -77,52 +88,51 @@ public class SubmitApprovalDecisionHandler(
Id = Guid.NewGuid(),
ApprovalRequestId = approval.Id,
Decision = normalizedDecision,
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
Comment = null,
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
DecidedByName = decidedByName,
DecidedByEmail = decidedByEmail,
CreatedAt = DateTimeOffset.UtcNow,
};
approval.State = normalizedDecision;
approval.CompletedAt = DateTimeOffset.UtcNow;
ApprovalWorkflowDecisionResult workflowDecisionResult = await approvalWorkflowRuntimeService
.ApplyWorkflowStepDecisionAsync(approval, contentItem, workspace, User!, decision, ct);
if (approval.Stage == "Internal")
if (!workflowDecisionResult.Succeeded)
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Ready for client review",
"Changes requested" => "Changes requested internally",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
}
else if (approval.Stage == "Client")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Approved",
"Changes requested" => "Changes requested by client",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
AddError(request => request.Decision, workflowDecisionResult.ErrorMessage ?? "The approval decision could not be recorded.");
await SendErrorsAsync(workflowDecisionResult.StatusCode, ct);
return;
}
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
if (!workflowDecisionResult.IsWorkflowStep)
{
approval.State = normalizedDecision;
approval.CompletedAt = DateTimeOffset.UtcNow;
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.decision.recorded",
"ApprovalDecision",
decision.Id,
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
null,
decidedByEmail,
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
ct);
if (normalizedDecision == "Approved")
{
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
workspace.SchedulePostsAutomaticallyOnApproval,
contentItem.DueDate);
}
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.decision.recorded",
"ApprovalDecision",
decision.Id,
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
null,
decidedByEmail,
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
ct);
}
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
@@ -158,6 +168,11 @@ public class SubmitApprovalDecisionHandler(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,

View File

@@ -0,0 +1,56 @@
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
public static class ApprovalStepTargetTypes
{
public const string Role = "Role";
public const string Membership = "Membership";
public const string Member = "Member";
}
public static class ApprovalMembershipTargets
{
public const string Team = "Team";
public const string Client = "Client";
}
public static class ApprovalStepConfigurationRules
{
public static readonly IReadOnlySet<string> AllowedTargetTypes = new HashSet<string>(StringComparer.Ordinal)
{
ApprovalStepTargetTypes.Role,
ApprovalStepTargetTypes.Membership,
ApprovalStepTargetTypes.Member,
};
public static readonly IReadOnlySet<string> AllowedRoleTargets = new HashSet<string>(StringComparer.Ordinal)
{
KnownRoles.Administrator,
KnownRoles.Manager,
KnownRoles.WorkspaceMember,
KnownRoles.Client,
KnownRoles.Provider,
};
public static readonly IReadOnlySet<string> AllowedMembershipTargets = new HashSet<string>(StringComparer.Ordinal)
{
ApprovalMembershipTargets.Team,
ApprovalMembershipTargets.Client,
};
public static bool IsValidTargetType(string? targetType)
{
return !string.IsNullOrWhiteSpace(targetType) && AllowedTargetTypes.Contains(targetType.Trim());
}
public static bool IsValidRoleTarget(string? targetValue)
{
return !string.IsNullOrWhiteSpace(targetValue) && AllowedRoleTargets.Contains(targetValue.Trim());
}
public static bool IsValidMembershipTarget(string? targetValue)
{
return !string.IsNullOrWhiteSpace(targetValue) && AllowedMembershipTargets.Contains(targetValue.Trim());
}
}

View File

@@ -0,0 +1,97 @@
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
public static class ApprovalModes
{
public const string None = "None";
public const string Optional = "Optional";
public const string Required = "Required";
public const string MultiLevel = "Multi-level";
}
public static class ApprovalWorkflowRules
{
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
{
return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel;
}
public static bool IsApprovalCompletionStatus(string status)
{
return status is "Approved" or "Scheduled";
}
public static string GetFinalApprovalStatus(bool schedulePostsAutomaticallyOnApproval, DateTimeOffset? plannedPublishDate)
{
return schedulePostsAutomaticallyOnApproval && plannedPublishDate.HasValue
? "Scheduled"
: "Approved";
}
public static bool HasRequiredStepApprovals(int approvedDecisionCount, int requiredApproverCount)
{
return approvedDecisionCount >= Math.Max(1, requiredApproverCount);
}
public static bool CanApproveWorkflowStep(
bool isAdministrator,
bool hasWorkspaceAccess,
IReadOnlyCollection<string> userRoles,
Guid userId,
string? targetType,
string? targetValue)
{
if (isAdministrator)
{
return true;
}
if (!hasWorkspaceAccess ||
string.IsNullOrWhiteSpace(targetType) ||
string.IsNullOrWhiteSpace(targetValue))
{
return false;
}
return targetType switch
{
ApprovalStepTargetTypes.Role => userRoles.Contains(targetValue),
ApprovalStepTargetTypes.Membership => MatchesMembershipTarget(userRoles, targetValue),
ApprovalStepTargetTypes.Member => ParseMemberTargetIds(targetValue).Contains(userId),
_ => false,
};
}
public static IReadOnlyCollection<Guid> ParseMemberTargetIds(string? targetValue)
{
if (string.IsNullOrWhiteSpace(targetValue))
{
return [];
}
return targetValue
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(value => Guid.TryParse(value, out Guid memberUserId) ? memberUserId : Guid.Empty)
.Where(memberUserId => memberUserId != Guid.Empty)
.Distinct()
.ToArray();
}
public static string FormatMemberTargetValue(IEnumerable<Guid> memberUserIds)
{
return string.Join(",", memberUserIds.Distinct().OrderBy(memberUserId => memberUserId));
}
private static bool MatchesMembershipTarget(
IReadOnlyCollection<string> userRoles,
string targetValue)
{
return targetValue switch
{
ApprovalMembershipTargets.Client => userRoles.Contains(KnownRoles.Client),
ApprovalMembershipTargets.Team => !userRoles.Contains(KnownRoles.Client),
_ => false,
};
}
}

View File

@@ -0,0 +1,401 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Services;
public record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage);
public record ApprovalWorkflowDecisionResult(
bool Succeeded,
string? ErrorMessage,
int StatusCode,
bool IsWorkflowStep);
public class ApprovalWorkflowRuntimeService(
AppDbContext dbContext,
INotificationEventWriter notificationEventWriter)
{
private const string PendingState = "Pending";
private const string ApprovedState = "Approved";
public async Task<ApprovalWorkflowStartResult> StartMultiLevelWorkflowAsync(
ContentItem contentItem,
Workspace workspace,
Guid requestedByUserId,
CancellationToken ct)
{
if (workspace.ApprovalMode != ApprovalModes.MultiLevel)
{
return new ApprovalWorkflowStartResult(false, "The workspace is not configured for multi-level approval.");
}
ApprovalWorkflowInstance? activeWorkflow = await dbContext.ApprovalWorkflowInstances
.SingleOrDefaultAsync(
workflow => workflow.ContentItemId == contentItem.Id && workflow.State == PendingState,
ct);
if (activeWorkflow is not null)
{
contentItem.Status = "In approval";
return new ApprovalWorkflowStartResult(true, null);
}
List<WorkspaceApprovalStepConfiguration> configuredSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.OrderBy(step => step.SortOrder)
.ThenBy(step => step.Name)
.ToListAsync(ct);
if (configuredSteps.Count == 0)
{
return new ApprovalWorkflowStartResult(false, "Multi-level approval requires at least one configured approval step.");
}
DateTimeOffset now = DateTimeOffset.UtcNow;
var workflowInstance = new ApprovalWorkflowInstance
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
ContentItemId = contentItem.Id,
State = PendingState,
ApprovalMode = workspace.ApprovalMode,
StartedAt = now,
};
List<ApprovalRequest> workflowSteps = configuredSteps
.Select((step, index) => new ApprovalRequest
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
ContentItemId = contentItem.Id,
WorkflowInstanceId = workflowInstance.Id,
WorkflowStepSortOrder = index,
WorkflowStepTargetType = step.TargetType,
WorkflowStepTargetValue = step.TargetValue,
WorkflowStepRequiredApproverCount = step.RequiredApproverCount,
Stage = step.Name,
ReviewerName = FormatStepTarget(step),
ReviewerEmail = string.Empty,
RequestedByUserId = requestedByUserId,
DueAt = contentItem.DueDate,
State = PendingState,
AccessToken = CreateAccessToken(),
SentAt = now,
})
.ToList();
dbContext.ApprovalWorkflowInstances.Add(workflowInstance);
dbContext.ApprovalRequests.AddRange(workflowSteps);
contentItem.Status = "In approval";
await dbContext.SaveChangesAsync(ct);
await NotifyCurrentStepApproversAsync(workflowSteps[0], contentItem, ct);
return new ApprovalWorkflowStartResult(true, null);
}
public async Task<ApprovalWorkflowDecisionResult> ApplyWorkflowStepDecisionAsync(
ApprovalRequest approval,
ContentItem contentItem,
Workspace workspace,
ClaimsPrincipal user,
ApprovalDecision decision,
CancellationToken ct)
{
if (!approval.WorkflowInstanceId.HasValue)
{
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, false);
}
if (user.Identity?.IsAuthenticated != true)
{
return new ApprovalWorkflowDecisionResult(false, "Multi-level approval steps require an authenticated approver.", StatusCodes.Status401Unauthorized, true);
}
if (!await CanApproveStepAsync(user, approval, workspace.Id, ct))
{
return new ApprovalWorkflowDecisionResult(false, "You cannot approve the current workflow step.", StatusCodes.Status403Forbidden, true);
}
ApprovalRequest? currentStep = await GetCurrentPendingStepAsync(approval.WorkflowInstanceId.Value, ct);
if (currentStep?.Id != approval.Id)
{
return new ApprovalWorkflowDecisionResult(false, "Only the current pending approval step can be approved.", StatusCodes.Status409Conflict, true);
}
Guid currentUserId = user.GetUserId();
bool alreadyApproved = await dbContext.ApprovalDecisions.AnyAsync(
candidate => candidate.ApprovalRequestId == approval.Id &&
candidate.DecidedByUserId == currentUserId &&
candidate.Decision == ApprovedState,
ct);
if (alreadyApproved)
{
return new ApprovalWorkflowDecisionResult(false, "You have already approved this workflow step.", StatusCodes.Status409Conflict, true);
}
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
int approvedCount = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState)
.Select(candidate => candidate.DecidedByUserId.HasValue
? candidate.DecidedByUserId.Value.ToString()
: candidate.DecidedByEmail.ToLower())
.Distinct()
.CountAsync(ct);
int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1;
if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount))
{
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
}
approval.State = ApprovedState;
approval.CompletedAt = DateTimeOffset.UtcNow;
ApprovalRequest? nextStep = await dbContext.ApprovalRequests
.Where(candidate => candidate.WorkflowInstanceId == approval.WorkflowInstanceId &&
candidate.State == PendingState &&
candidate.Id != approval.Id)
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
.ThenBy(candidate => candidate.SentAt)
.FirstOrDefaultAsync(ct);
if (nextStep is null)
{
ApprovalWorkflowInstance? workflowInstance = await dbContext.ApprovalWorkflowInstances
.SingleOrDefaultAsync(candidate => candidate.Id == approval.WorkflowInstanceId.Value, ct);
if (workflowInstance is null)
{
return new ApprovalWorkflowDecisionResult(false, "The approval workflow instance could not be found.", StatusCodes.Status404NotFound, true);
}
workflowInstance.State = ApprovedState;
workflowInstance.CompletedAt = DateTimeOffset.UtcNow;
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
workspace.SchedulePostsAutomaticallyOnApproval,
contentItem.DueDate);
}
await dbContext.SaveChangesAsync(ct);
if (nextStep is null)
{
await NotifyPublishUsersAsync(approval, contentItem, ct);
}
else
{
await NotifyCurrentStepApproversAsync(nextStep, contentItem, ct);
}
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
}
public async Task<bool> HasCompletedMultiLevelWorkflowAsync(Guid contentItemId, CancellationToken ct)
{
return await dbContext.ApprovalWorkflowInstances.AnyAsync(
workflow => workflow.ContentItemId == contentItemId && workflow.State == ApprovedState,
ct);
}
private async Task<ApprovalRequest?> GetCurrentPendingStepAsync(Guid workflowInstanceId, CancellationToken ct)
{
return await dbContext.ApprovalRequests
.Where(candidate => candidate.WorkflowInstanceId == workflowInstanceId && candidate.State == PendingState)
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
.ThenBy(candidate => candidate.SentAt)
.FirstOrDefaultAsync(ct);
}
private async Task<bool> CanApproveStepAsync(
ClaimsPrincipal user,
ApprovalRequest approval,
Guid workspaceId,
CancellationToken ct)
{
Guid userId = user.GetUserId();
bool hasWorkspaceAccess = await UserHasWorkspaceAccessAsync(userId, workspaceId, ct);
string[] userRoles = ApprovalStepConfigurationRules.AllowedRoleTargets
.Where(user.IsInRole)
.ToArray();
return ApprovalWorkflowRules.CanApproveWorkflowStep(
user.IsInRole(KnownRoles.Administrator),
hasWorkspaceAccess,
userRoles,
userId,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue);
}
private async Task<bool> UserHasWorkspaceAccessAsync(Guid userId, Guid workspaceId, CancellationToken ct)
{
string workspaceClaimValue = workspaceId.ToString();
return await dbContext.UserClaims.AnyAsync(
claim => claim.UserId == userId &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue,
ct);
}
private async Task NotifyCurrentStepApproversAsync(
ApprovalRequest approval,
ContentItem contentItem,
CancellationToken ct)
{
List<ApprovalNotificationRecipient> recipients = await GetStepApproverRecipientsAsync(approval, ct);
foreach (ApprovalNotificationRecipient recipient in recipients)
{
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.step.current",
"ApprovalRequest",
approval.Id,
$"{approval.Stage} approval is ready for {contentItem.Title}.",
recipient.UserId,
recipient.Email,
$$"""{"stage":"{{approval.Stage}}","requiredApproverCount":{{approval.WorkflowStepRequiredApproverCount ?? 1}}}"""),
ct);
}
}
private async Task NotifyPublishUsersAsync(
ApprovalRequest approval,
ContentItem contentItem,
CancellationToken ct)
{
List<ApprovalNotificationRecipient> recipients = await GetPublishRecipientUsersAsync(approval.WorkspaceId, ct);
foreach (ApprovalNotificationRecipient recipient in recipients)
{
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.workflow.completed",
"ApprovalWorkflowInstance",
approval.WorkflowInstanceId!.Value,
$"Final approval completed for {contentItem.Title}.",
recipient.UserId,
recipient.Email,
$$"""{"status":"{{contentItem.Status}}"}"""),
ct);
}
}
private async Task<List<ApprovalNotificationRecipient>> GetStepApproverRecipientsAsync(
ApprovalRequest approval,
CancellationToken ct)
{
string? targetType = approval.WorkflowStepTargetType;
string? targetValue = approval.WorkflowStepTargetValue;
if (string.IsNullOrWhiteSpace(targetType) || string.IsNullOrWhiteSpace(targetValue))
{
return [];
}
return targetType switch
{
ApprovalStepTargetTypes.Member => await GetMemberRecipientsAsync(targetValue, ct),
ApprovalStepTargetTypes.Role => await GetRoleRecipientsAsync(approval.WorkspaceId, [targetValue], ct),
ApprovalStepTargetTypes.Membership => await GetMembershipRecipientsAsync(approval.WorkspaceId, targetValue, ct),
_ => [],
};
}
private async Task<List<ApprovalNotificationRecipient>> GetMemberRecipientsAsync(string targetValue, CancellationToken ct)
{
IReadOnlyCollection<Guid> userIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue);
if (userIds.Count == 0)
{
return [];
}
return await dbContext.Users
.Where(user => userIds.Contains(user.Id))
.Select(user => new ApprovalNotificationRecipient(user.Id, user.Email))
.ToListAsync(ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetMembershipRecipientsAsync(
Guid workspaceId,
string targetValue,
CancellationToken ct)
{
string[] roles = targetValue switch
{
ApprovalMembershipTargets.Client => [KnownRoles.Client],
ApprovalMembershipTargets.Team => [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember, KnownRoles.Provider],
_ => [],
};
return roles.Length == 0
? []
: await GetRoleRecipientsAsync(workspaceId, roles, ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetPublishRecipientUsersAsync(Guid workspaceId, CancellationToken ct)
{
return await GetRoleRecipientsAsync(workspaceId, [KnownRoles.Administrator, KnownRoles.Manager], ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetRoleRecipientsAsync(
Guid workspaceId,
IReadOnlyCollection<string> roles,
CancellationToken ct)
{
string workspaceClaimValue = workspaceId.ToString();
return await dbContext.UserRoles
.Join(
dbContext.Roles,
userRole => userRole.RoleId,
role => role.Id,
(userRole, role) => new { userRole.UserId, RoleName = role.Name })
.Where(candidate => candidate.RoleName != null && roles.Contains(candidate.RoleName))
.Join(
dbContext.UserClaims.Where(claim =>
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue),
candidate => candidate.UserId,
claim => claim.UserId,
(candidate, _) => candidate.UserId)
.Distinct()
.Join(
dbContext.Users,
userId => userId,
user => user.Id,
(_, user) => new ApprovalNotificationRecipient(user.Id, user.Email))
.ToListAsync(ct);
}
private static string FormatStepTarget(WorkspaceApprovalStepConfiguration step)
{
return step.TargetType switch
{
ApprovalStepTargetTypes.Role => $"Role: {step.TargetValue}",
ApprovalStepTargetTypes.Membership => $"Membership: {step.TargetValue}",
ApprovalStepTargetTypes.Member => "Assigned members",
_ => step.TargetValue,
};
}
private static string CreateAccessToken()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
}
private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email);
}

View File

@@ -51,7 +51,7 @@ public class CreateAssetRevisionHandler(
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
if (contentItem is not null &&
!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -58,7 +58,7 @@ public class CreateGoogleDriveAssetHandler(
return;
}
if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -52,7 +52,7 @@ public class GetAssetsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -1,6 +1,6 @@
namespace Socialize.Api.Modules.Projects.Data;
namespace Socialize.Api.Modules.Campaigns.Data;
public class Project
public class Campaign
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Campaigns.Data;
public static class CampaignModelConfiguration
{
public static ModelBuilder ConfigureCampaignsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<Campaign>(campaign =>
{
campaign.ToTable("Campaigns");
campaign.HasKey(x => x.Id);
campaign.Property(x => x.Name).HasMaxLength(256).IsRequired();
campaign.Property(x => x.Description).HasMaxLength(4000);
campaign.Property(x => x.Notes).HasMaxLength(4000);
campaign.Property(x => x.Status).HasMaxLength(64).IsRequired();
campaign.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
campaign.HasIndex(x => new { x.ClientId, x.Name }).IsUnique();
campaign.HasIndex(x => x.WorkspaceId);
campaign.HasIndex(x => x.ClientId);
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,12 @@
using Socialize.Api.Modules.Campaigns.Data;
namespace Socialize.Api.Modules.Campaigns;
public static class DependencyInjection
{
public static WebApplicationBuilder AddCampaignsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -2,11 +2,11 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Projects.Data;
using Socialize.Api.Modules.Campaigns.Data;
namespace Socialize.Api.Modules.Projects.Handlers;
namespace Socialize.Api.Modules.Campaigns.Handlers;
public record CreateProjectRequest(
public record CreateCampaignRequest(
Guid WorkspaceId,
Guid ClientId,
string Name,
@@ -15,10 +15,10 @@ public record CreateProjectRequest(
string? Description,
string? Notes);
public class CreateProjectRequestValidator
: Validator<CreateProjectRequest>
public class CreateCampaignRequestValidator
: Validator<CreateCampaignRequest>
{
public CreateProjectRequestValidator()
public CreateCampaignRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ClientId).NotEmpty();
@@ -32,20 +32,20 @@ public class CreateProjectRequestValidator
}
}
public class CreateProjectHandler(
public class CreateCampaignHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateProjectRequest, ProjectDto>
: Endpoint<CreateCampaignRequest, CampaignDto>
{
public override void Configure()
{
Post("/api/projects");
Options(o => o.WithTags("Projects"));
Post("/api/campaigns");
Options(o => o.WithTags("Campaigns"));
}
public override async Task HandleAsync(CreateProjectRequest request, CancellationToken ct)
public override async Task HandleAsync(CreateCampaignRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -75,19 +75,19 @@ public class CreateProjectHandler(
string normalizedName = request.Name.Trim();
bool duplicateProject = await dbContext.Projects
bool duplicateCampaign = await dbContext.Campaigns
.AnyAsync(
project => project.ClientId == request.ClientId && project.Name == normalizedName,
campaign => campaign.ClientId == request.ClientId && campaign.Name == normalizedName,
ct);
if (duplicateProject)
if (duplicateCampaign)
{
AddError(request => request.Name, "A project with this name already exists for the selected client.");
AddError(request => request.Name, "A campaign with this name already exists for the selected client.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
Project project = new()
Campaign campaign = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
@@ -101,19 +101,19 @@ public class CreateProjectHandler(
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Projects.Add(project);
dbContext.Campaigns.Add(campaign);
await dbContext.SaveChangesAsync(ct);
ProjectDto dto = new(
project.Id,
project.WorkspaceId,
project.ClientId,
project.Name,
project.Description,
project.Notes,
project.Status,
project.StartDate,
project.EndDate);
CampaignDto dto = new(
campaign.Id,
campaign.WorkspaceId,
campaign.ClientId,
campaign.Name,
campaign.Description,
campaign.Notes,
campaign.Status,
campaign.StartDate,
campaign.EndDate);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}

View File

@@ -0,0 +1,82 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Campaigns.Data;
namespace Socialize.Api.Modules.Campaigns.Handlers;
public record GetCampaignsRequest(Guid? WorkspaceId, Guid? ClientId);
public record CampaignDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
string Name,
string? Description,
string? Notes,
string Status,
DateTimeOffset StartDate,
DateTimeOffset EndDate);
public class GetCampaignsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetCampaignsRequest, IReadOnlyCollection<CampaignDto>>
{
public override void Configure()
{
Get("/api/campaigns");
Options(o => o.WithTags("Campaigns"));
}
public override async Task HandleAsync(GetCampaignsRequest request, CancellationToken ct)
{
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId));
}
if (campaignScopeIds.Count > 0)
{
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id));
}
}
if (request.ClientId.HasValue)
{
query = query.Where(campaign => campaign.ClientId == request.ClientId.Value);
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value);
}
List<CampaignDto> campaigns = await query
.OrderBy(campaign => campaign.Name)
.Select(campaign => new CampaignDto(
campaign.Id,
campaign.WorkspaceId,
campaign.ClientId,
campaign.Name,
campaign.Description,
campaign.Notes,
campaign.Status,
campaign.StartDate,
campaign.EndDate))
.ToListAsync(ct);
await SendOkAsync(campaigns, ct);
}
}

View File

@@ -47,7 +47,7 @@ public class ChangeClientPortraitHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -41,7 +41,7 @@ public class CreateClientHandler(
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -33,16 +33,9 @@ public class GetClientsHandler(
{
IQueryable<Client> query = dbContext.Clients.AsQueryable();
if (accessScopeService.IsManager(User))
if (!accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
@@ -52,10 +45,11 @@ public class GetClientsHandler(
query = query.Where(client => clientScopeIds.Contains(client.Id));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
List<ClientDto> clients = await query

View File

@@ -50,7 +50,7 @@ public class UpdateClientHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -51,7 +51,7 @@ public class CreateCommentHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
if (!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -44,7 +44,7 @@ public class GetCommentsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -39,8 +39,8 @@ public class ResolveCommentHandler(
return;
}
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
bool canResolve = await accessScopeService.CanManageWorkspaceAsync(User, comment.WorkspaceId, ct)
|| await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct);
if (!canResolve)
{

View File

@@ -5,7 +5,7 @@ public class ContentItem
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ClientId { get; set; }
public Guid ProjectId { get; set; }
public Guid CampaignId { get; set; }
public required string Title { get; set; }
public required string PublicationMessage { get; set; }
public required string PublicationTargets { get; set; }

View File

@@ -21,7 +21,7 @@ public static class ContentItemModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP");
contentItem.HasIndex(x => x.WorkspaceId);
contentItem.HasIndex(x => x.ClientId);
contentItem.HasIndex(x => x.ProjectId);
contentItem.HasIndex(x => x.CampaignId);
});
modelBuilder.Entity<ContentItemRevision>(revision =>

View File

@@ -11,7 +11,7 @@ namespace Socialize.Api.Modules.ContentItems.Handlers;
public record CreateContentItemRequest(
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
Guid CampaignId,
string Title,
string PublicationMessage,
string PublicationTargets,
@@ -25,7 +25,7 @@ public class CreateContentItemRequestValidator
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ClientId).NotEmpty();
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.CampaignId).NotEmpty();
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
@@ -47,7 +47,7 @@ public class CreateContentItemHandler(
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
{
if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, request.WorkspaceId, request.ClientId, request.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -75,16 +75,16 @@ public class CreateContentItemHandler(
return;
}
bool projectExists = await dbContext.Projects
bool campaignExists = await dbContext.Campaigns
.AnyAsync(
project => project.Id == request.ProjectId &&
project.WorkspaceId == request.WorkspaceId &&
project.ClientId == request.ClientId,
campaign => campaign.Id == request.CampaignId &&
campaign.WorkspaceId == request.WorkspaceId &&
campaign.ClientId == request.ClientId,
ct);
if (!projectExists)
if (!campaignExists)
{
AddError(request => request.ProjectId, "The selected project does not belong to the selected client.");
AddError(request => request.CampaignId, "The selected campaign does not belong to the selected client.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
@@ -94,7 +94,7 @@ public class CreateContentItemHandler(
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ClientId = request.ClientId,
ProjectId = request.ProjectId,
CampaignId = request.CampaignId,
Title = request.Title.Trim(),
PublicationMessage = request.PublicationMessage.Trim(),
PublicationTargets = request.PublicationTargets.Trim(),
@@ -138,7 +138,7 @@ public class CreateContentItemHandler(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.CampaignId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,

View File

@@ -50,7 +50,7 @@ public class CreateContentItemRevisionHandler(
return;
}
if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -66,15 +66,6 @@ public class CreateContentItemRevisionHandler(
item.CurrentRevisionNumber = revisionNumber;
item.CurrentRevisionLabel = revisionLabel;
if (item.Status == "Changes requested internally")
{
item.Status = "Internal changes in progress";
}
else if (item.Status == "Changes requested by client")
{
item.Status = "Client changes in progress";
}
ContentItemRevision revision = new()
{
Id = Guid.NewGuid(),

View File

@@ -10,7 +10,7 @@ public record ContentItemDetailDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
Guid CampaignId,
string Title,
string PublicationMessage,
string PublicationTargets,
@@ -42,7 +42,7 @@ public class GetContentItemHandler(
candidate.Id,
candidate.WorkspaceId,
candidate.ClientId,
candidate.ProjectId,
candidate.CampaignId,
candidate.Title,
candidate.PublicationMessage,
candidate.PublicationTargets,
@@ -60,7 +60,7 @@ public class GetContentItemHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -41,7 +41,7 @@ public class GetContentItemRevisionsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -6,13 +6,13 @@ using Socialize.Api.Modules.ContentItems.Data;
namespace Socialize.Api.Modules.ContentItems.Handlers;
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? CampaignId);
public record ContentItemDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
Guid CampaignId,
string Title,
string PublicationMessage,
string PublicationTargets,
@@ -39,9 +39,9 @@ public class GetContentItemsHandler(
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
@@ -50,9 +50,9 @@ public class GetContentItemsHandler(
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
}
if (projectScopeIds.Count > 0)
if (campaignScopeIds.Count > 0)
{
query = query.Where(item => projectScopeIds.Contains(item.ProjectId));
query = query.Where(item => campaignScopeIds.Contains(item.CampaignId));
}
}
@@ -61,9 +61,9 @@ public class GetContentItemsHandler(
query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value);
}
if (request.ProjectId.HasValue)
if (request.CampaignId.HasValue)
{
query = query.Where(item => item.ProjectId == request.ProjectId.Value);
query = query.Where(item => item.CampaignId == request.CampaignId.Value);
}
if (request.ClientId.HasValue)
@@ -78,7 +78,7 @@ public class GetContentItemsHandler(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.CampaignId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,

View File

@@ -2,8 +2,10 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.ContentItems.Handlers;
@@ -21,24 +23,18 @@ public class UpdateContentItemStatusRequestValidator
public class UpdateContentItemStatusHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
{
private static readonly HashSet<string> AllowedStatuses =
[
"Draft",
"In internal review",
"Changes requested internally",
"Internal changes in progress",
"Ready for client review",
"In client review",
"Changes requested by client",
"Client changes in progress",
"In production",
"In approval",
"Approved",
"Rejected",
"Ready to publish",
"Scheduled",
"Published",
"Archived",
];
public override void Configure()
@@ -58,7 +54,7 @@ public class UpdateContentItemStatusHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, item.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -72,7 +68,64 @@ public class UpdateContentItemStatusHandler(
return;
}
item.Status = normalizedStatus;
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == item.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (normalizedStatus == "In approval" && workspace.ApprovalMode == ApprovalModes.MultiLevel)
{
ApprovalWorkflowStartResult startResult = await approvalWorkflowRuntimeService.StartMultiLevelWorkflowAsync(
item,
workspace,
User.GetUserId(),
ct);
if (!startResult.Succeeded)
{
AddError(request => request.Status, startResult.ErrorMessage ?? "The approval workflow could not be started.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
else if (ApprovalWorkflowRules.IsApprovalCompletionStatus(normalizedStatus) &&
ApprovalWorkflowRules.BlocksManualApprovedOrScheduledStatus(workspace.ApprovalMode))
{
if (workspace.ApprovalMode == ApprovalModes.MultiLevel)
{
bool hasCompletedWorkflow = await approvalWorkflowRuntimeService.HasCompletedMultiLevelWorkflowAsync(item.Id, ct);
if (!hasCompletedWorkflow)
{
AddError(request => request.Status, "This workspace requires the multi-level approval workflow to complete before content can be approved or scheduled.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
else
{
bool hasApprovedDecision = await dbContext.ApprovalRequests.AnyAsync(
approval => approval.ContentItemId == item.Id &&
approval.WorkspaceId == item.WorkspaceId &&
approval.State == "Approved" &&
approval.CompletedAt.HasValue,
ct);
if (!hasApprovedDecision)
{
AddError(request => request.Status, "This workspace requires approval before content can be approved or scheduled.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
}
if (item.Status != "In approval" || normalizedStatus != "In approval")
{
item.Status = normalizedStatus;
}
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
@@ -92,7 +145,7 @@ public class UpdateContentItemStatusHandler(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.CampaignId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,

View File

@@ -7,8 +7,8 @@ public record FeedbackContextDto(
string? WorkspaceName,
Guid? ClientId,
string? ClientName,
Guid? ProjectId,
string? ProjectName,
Guid? CampaignId,
string? CampaignName,
Guid? ContentItemId,
string? ContentItemTitle);
@@ -82,8 +82,8 @@ public static class FeedbackDtoMapper
report.WorkspaceName,
report.ClientId,
report.ClientName,
report.ProjectId,
report.ProjectName,
report.CampaignId,
report.CampaignName,
report.ContentItemId,
report.ContentItemTitle),
report.Screenshot is null

View File

@@ -20,7 +20,7 @@ public static class FeedbackModelConfiguration
feedback.Property(x => x.AppVersion).HasMaxLength(128);
feedback.Property(x => x.WorkspaceName).HasMaxLength(256);
feedback.Property(x => x.ClientName).HasMaxLength(256);
feedback.Property(x => x.ProjectName).HasMaxLength(256);
feedback.Property(x => x.CampaignName).HasMaxLength(256);
feedback.Property(x => x.ContentItemTitle).HasMaxLength(256);
feedback.Property(x => x.CancellationReason).HasMaxLength(2000);
feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");

View File

@@ -18,8 +18,8 @@ public class FeedbackReport
public string? WorkspaceName { get; set; }
public Guid? ClientId { get; set; }
public string? ClientName { get; set; }
public Guid? ProjectId { get; set; }
public string? ProjectName { get; set; }
public Guid? CampaignId { get; set; }
public string? CampaignName { get; set; }
public Guid? ContentItemId { get; set; }
public string? ContentItemTitle { get; set; }
public DateTimeOffset CreatedAt { get; set; }

View File

@@ -19,8 +19,8 @@ public record SubmitFeedbackRequest(
string? WorkspaceName,
Guid? ClientId,
string? ClientName,
Guid? ProjectId,
string? ProjectName,
Guid? CampaignId,
string? CampaignName,
Guid? ContentItemId,
string? ContentItemTitle);
@@ -36,7 +36,7 @@ public class SubmitFeedbackRequestValidator
RuleFor(x => x.AppVersion).MaximumLength(128);
RuleFor(x => x.WorkspaceName).MaximumLength(256);
RuleFor(x => x.ClientName).MaximumLength(256);
RuleFor(x => x.ProjectName).MaximumLength(256);
RuleFor(x => x.CampaignName).MaximumLength(256);
RuleFor(x => x.ContentItemTitle).MaximumLength(256);
RuleFor(x => x.ViewportWidth).GreaterThan(0).When(x => x.ViewportWidth.HasValue);
RuleFor(x => x.ViewportHeight).GreaterThan(0).When(x => x.ViewportHeight.HasValue);
@@ -82,8 +82,8 @@ public class SubmitFeedbackHandler(
WorkspaceName = NormalizeOptional(request.WorkspaceName),
ClientId = request.ClientId,
ClientName = NormalizeOptional(request.ClientName),
ProjectId = request.ProjectId,
ProjectName = NormalizeOptional(request.ProjectName),
CampaignId = request.CampaignId,
CampaignName = NormalizeOptional(request.CampaignName),
ContentItemId = request.ContentItemId,
ContentItemTitle = NormalizeOptional(request.ContentItemTitle),
CreatedAt = now,

View File

@@ -2,10 +2,10 @@
public static class KnownRoles
{
public const string Administrator = nameof(Administrator);
public const string Manager = nameof(Manager);
public const string Client = nameof(Client);
public const string Provider = nameof(Provider);
public const string WorkspaceMember = nameof(WorkspaceMember);
public const string Developer = nameof(Developer);
public const string Administrator = "administrator";
public const string Manager = "manager";
public const string Client = "client";
public const string Provider = "provider";
public const string WorkspaceMember = "workspace-member";
public const string Developer = "developer";
}

View File

@@ -50,8 +50,8 @@ public class GetCurrentUserQueryHandler(
.Distinct()
.ToList();
List<Guid> projectIds = claims
.Where(claim => claim.Type == KnownClaims.ProjectScope)
List<Guid> campaignIds = claims
.Where(claim => claim.Type == KnownClaims.CampaignScope)
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
@@ -64,7 +64,7 @@ public class GetCurrentUserQueryHandler(
Persona = persona,
AuthorizedWorkspaceIds = workspaceIds,
AuthorizedClientIds = clientIds,
AuthorizedProjectIds = projectIds,
AuthorizedCampaignIds = campaignIds,
Alias = userModel.Alias,
PortraitUrl = userModel.PortraitUrl,
Firstname = userModel.Firstname,

View File

@@ -7,7 +7,7 @@ public class UserDto
public string? Persona { get; init; }
public IList<Guid> AuthorizedWorkspaceIds { get; init; } = [];
public IList<Guid> AuthorizedClientIds { get; init; } = [];
public IList<Guid> AuthorizedProjectIds { get; init; } = [];
public IList<Guid> AuthorizedCampaignIds { get; init; } = [];
public string Username { get; init; } = null!;
public string? Alias { get; init; }
public string? PortraitUrl { get; init; }

View File

@@ -46,7 +46,7 @@ public class GetNotificationsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -58,7 +58,7 @@ public class GetNotificationsHandler(
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(notificationEvent =>
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
notificationEvent.RecipientUserId == currentUserId);

View File

@@ -30,7 +30,7 @@ public class MarkNotificationAsReadHandler(
Guid currentUserId = User.GetUserId();
bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId;
if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
if (!canReadRecipientNotification && !await accessScopeService.CanAccessWorkspaceAsync(User, notificationEvent.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.Organizations.Data;
public class Organization
{
public Guid Id { get; init; }
public required string Name { get; set; }
public Guid OwnerUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Organizations.Data;
public class OrganizationMembership
{
public Guid Id { get; init; }
public Guid OrganizationId { get; set; }
public Guid UserId { get; set; }
public required string Role { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Organizations.Data;
public static class OrganizationModelConfiguration
{
public static ModelBuilder ConfigureOrganizationsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<Organization>(organization =>
{
organization.ToTable("Organizations");
organization.HasKey(x => x.Id);
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
organization.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
organization.HasIndex(x => x.OwnerUserId);
});
modelBuilder.Entity<OrganizationMembership>(membership =>
{
membership.ToTable("OrganizationMemberships");
membership.HasKey(x => x.Id);
membership.Property(x => x.Role).HasMaxLength(64).IsRequired();
membership.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
membership.HasIndex(x => x.OrganizationId);
membership.HasIndex(x => x.UserId);
membership.HasIndex(x => new { x.OrganizationId, x.UserId }).IsUnique();
membership.HasOne<Organization>()
.WithMany()
.HasForeignKey(x => x.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,14 @@
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.Organizations;
public static class DependencyInjection
{
public static WebApplicationBuilder AddOrganizationsModule(
this WebApplicationBuilder builder)
{
builder.Services.AddScoped<OrganizationAccessService>();
return builder;
}
}

View File

@@ -0,0 +1,114 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Handlers;
namespace Socialize.Api.Modules.Organizations.Handlers;
public class GetOrganizationHandler(
AppDbContext dbContext,
OrganizationAccessService organizationAccessService)
: EndpointWithoutRequest<OrganizationDto>
{
public override void Configure()
{
Get("/api/organizations/{organizationId:guid}");
Options(o => o.WithTags("Organizations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid organizationId = Route<Guid>("organizationId");
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await organizationAccessService.CanAccessOrganizationAsync(User, organizationId, ct))
{
await SendForbiddenAsync(ct);
return;
}
IReadOnlyCollection<string> currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
User,
organizationId,
ct);
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
IReadOnlyCollection<WorkspaceDto> workspaces = await GetWorkspacesAsync(organizationId, ct);
await SendOkAsync(
OrganizationDto.FromOrganization(
organization,
currentUserPermissions,
members,
workspaces),
ct);
}
private async Task<IReadOnlyCollection<OrganizationMemberDto>> GetMembersAsync(
Guid organizationId,
CancellationToken ct)
{
var rows = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == organizationId)
.Join(
dbContext.Users,
membership => membership.UserId,
user => user.Id,
(membership, user) => new { Membership = membership, User = user })
.OrderBy(row => row.User.Lastname)
.ThenBy(row => row.User.Firstname)
.ThenBy(row => row.User.Email)
.ToListAsync(ct);
return rows
.Select(row => new OrganizationMemberDto(
row.User.Id,
BuildDisplayName(row.User),
row.User.Email ?? string.Empty,
row.User.PortraitUrl,
row.Membership.Role,
OrganizationPermissionRules.GetPermissionsForRole(row.Membership.Role),
row.Membership.CreatedAt))
.ToArray();
}
private async Task<IReadOnlyCollection<WorkspaceDto>> GetWorkspacesAsync(
Guid organizationId,
CancellationToken ct)
{
var workspaces = await dbContext.Workspaces
.Where(workspace => workspace.OrganizationId == organizationId)
.OrderBy(workspace => workspace.Name)
.ToListAsync(ct);
return workspaces
.Select(workspace => WorkspaceDto.FromWorkspace(workspace, []))
.ToArray();
}
private static string BuildDisplayName(User user)
{
if (!string.IsNullOrWhiteSpace(user.Alias))
{
return user.Alias;
}
string fullName = $"{user.Firstname} {user.Lastname}".Trim();
if (!string.IsNullOrWhiteSpace(fullName))
{
return fullName;
}
return user.Email ?? user.UserName ?? user.Id.ToString();
}
}

View File

@@ -0,0 +1,41 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.Organizations.Handlers;
public class GetOrganizationsHandler(
AppDbContext dbContext,
OrganizationAccessService organizationAccessService)
: EndpointWithoutRequest<IReadOnlyCollection<OrganizationDto>>
{
public override void Configure()
{
Get("/api/organizations");
Options(o => o.WithTags("Organizations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
IReadOnlyCollection<Guid> organizationIds = await organizationAccessService.GetAccessibleOrganizationIdsAsync(User, ct);
List<Organization> organizations = await dbContext.Organizations
.Where(organization => organizationIds.Contains(organization.Id))
.OrderBy(organization => organization.Name)
.ToListAsync(ct);
List<OrganizationDto> response = [];
foreach (Organization organization in organizations)
{
IReadOnlyCollection<string> permissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
User,
organization.Id,
ct);
response.Add(OrganizationDto.FromOrganization(organization, permissions));
}
await SendOkAsync(response, ct);
}
}

View File

@@ -0,0 +1,39 @@
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Handlers;
namespace Socialize.Api.Modules.Organizations.Handlers;
public record OrganizationMemberDto(
Guid UserId,
string DisplayName,
string Email,
string? PortraitUrl,
string Role,
IReadOnlyCollection<string> Permissions,
DateTimeOffset CreatedAt);
public record OrganizationDto(
Guid Id,
string Name,
Guid OwnerUserId,
IReadOnlyCollection<string> CurrentUserPermissions,
IReadOnlyCollection<OrganizationMemberDto> Members,
IReadOnlyCollection<WorkspaceDto> Workspaces,
DateTimeOffset CreatedAt)
{
public static OrganizationDto FromOrganization(
Organization organization,
IReadOnlyCollection<string> currentUserPermissions,
IReadOnlyCollection<OrganizationMemberDto>? members = null,
IReadOnlyCollection<WorkspaceDto>? workspaces = null)
{
return new OrganizationDto(
organization.Id,
organization.Name,
organization.OwnerUserId,
currentUserPermissions,
members ?? [],
workspaces ?? [],
organization.CreatedAt);
}
}

View File

@@ -0,0 +1,202 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Organizations.Services;
public sealed class OrganizationAccessService(
AppDbContext dbContext)
{
public bool IsGlobalManager(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
}
public async Task<IReadOnlyCollection<Guid>> GetAccessibleOrganizationIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return await dbContext.Organizations
.Select(organization => organization.Id)
.ToArrayAsync(ct);
}
Guid userId = user.GetUserId();
Guid[] ownedOrganizationIds = await dbContext.Organizations
.Where(organization => organization.OwnerUserId == userId)
.Select(organization => organization.Id)
.ToArrayAsync(ct);
Guid[] memberOrganizationIds = await dbContext.OrganizationMemberships
.Where(membership => membership.UserId == userId)
.Select(membership => membership.OrganizationId)
.ToArrayAsync(ct);
return ownedOrganizationIds
.Concat(memberOrganizationIds)
.Distinct()
.ToArray();
}
public async Task<IReadOnlyCollection<Guid>> GetAccessibleWorkspaceIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return await dbContext.Workspaces
.Select(workspace => workspace.Id)
.ToArrayAsync(ct);
}
Guid[] directWorkspaceIds = user.GetWorkspaceScopeIds().ToArray();
Guid[] organizationWorkspaceIds = await GetInheritedWorkspaceIdsAsync(user, OrganizationPermissions.AccessOwnedWorkspaces, ct);
return directWorkspaceIds
.Concat(organizationWorkspaceIds)
.Distinct()
.ToArray();
}
public async Task<bool> CanAccessOrganizationAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid userId = user.GetUserId();
return await dbContext.Organizations.AnyAsync(
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
ct)
|| await dbContext.OrganizationMemberships.AnyAsync(
membership => membership.OrganizationId == organizationId && membership.UserId == userId,
ct);
}
public async Task<bool> HasOrganizationPermissionAsync(
ClaimsPrincipal user,
Guid organizationId,
string permission,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid userId = user.GetUserId();
bool owner = await dbContext.Organizations.AnyAsync(
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
ct);
if (owner)
{
return OrganizationPermissionRules.RoleHasPermission(OrganizationRoles.Owner, permission);
}
string[] roles = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == organizationId && membership.UserId == userId)
.Select(membership => membership.Role)
.ToArrayAsync(ct);
return roles.Any(role => OrganizationPermissionRules.RoleHasPermission(role, permission));
}
public async Task<IReadOnlyCollection<string>> GetUserOrganizationPermissionsAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
}
Guid userId = user.GetUserId();
bool owner = await dbContext.Organizations.AnyAsync(
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
ct);
if (owner)
{
return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
}
string[] roles = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == organizationId && membership.UserId == userId)
.Select(membership => membership.Role)
.ToArrayAsync(ct);
return roles
.SelectMany(OrganizationPermissionRules.GetPermissionsForRole)
.Distinct(StringComparer.Ordinal)
.OrderBy(permission => permission)
.ToArray();
}
public async Task<bool> HasInheritedWorkspacePermissionAsync(
ClaimsPrincipal user,
Guid workspaceId,
string permission,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid? organizationId = await dbContext.Workspaces
.Where(workspace => workspace.Id == workspaceId)
.Select(workspace => (Guid?)workspace.OrganizationId)
.SingleOrDefaultAsync(ct);
return organizationId.HasValue &&
await HasOrganizationPermissionAsync(user, organizationId.Value, permission, ct);
}
private async Task<Guid[]> GetInheritedWorkspaceIdsAsync(
ClaimsPrincipal user,
string permission,
CancellationToken ct)
{
Guid userId = user.GetUserId();
Guid[] ownedOrganizationIds = await dbContext.Organizations
.Where(organization => organization.OwnerUserId == userId)
.Select(organization => organization.Id)
.ToArrayAsync(ct);
List<Data.OrganizationMembership> memberships = await dbContext.OrganizationMemberships
.Where(membership => membership.UserId == userId)
.ToListAsync(ct);
Guid[] memberOrganizationIds = memberships
.Where(membership => OrganizationPermissionRules.RoleHasPermission(membership.Role, permission))
.Select(membership => membership.OrganizationId)
.ToArray();
Guid[] organizationIds = ownedOrganizationIds
.Concat(memberOrganizationIds)
.Distinct()
.ToArray();
if (organizationIds.Length == 0)
{
return [];
}
return await dbContext.Workspaces
.Where(workspace => organizationIds.Contains(workspace.OrganizationId))
.Select(workspace => workspace.Id)
.ToArrayAsync(ct);
}
}

View File

@@ -0,0 +1,50 @@
namespace Socialize.Api.Modules.Organizations.Services;
public static class OrganizationPermissionRules
{
public static IReadOnlyCollection<string> GetPermissionsForRole(string role)
{
return role switch
{
OrganizationRoles.Owner =>
[
OrganizationPermissions.ManageOrganizationSettings,
OrganizationPermissions.ManageOrganizationMembers,
OrganizationPermissions.CreateWorkspaces,
OrganizationPermissions.ManageWorkspaces,
OrganizationPermissions.ManageBilling,
OrganizationPermissions.ManageConnectors,
OrganizationPermissions.AccessOwnedWorkspaces,
],
OrganizationRoles.Admin =>
[
OrganizationPermissions.ManageOrganizationSettings,
OrganizationPermissions.ManageOrganizationMembers,
OrganizationPermissions.CreateWorkspaces,
OrganizationPermissions.ManageWorkspaces,
OrganizationPermissions.ManageConnectors,
OrganizationPermissions.AccessOwnedWorkspaces,
],
OrganizationRoles.BillingManager =>
[
OrganizationPermissions.ManageBilling,
OrganizationPermissions.AccessOwnedWorkspaces,
],
OrganizationRoles.ConnectorManager =>
[
OrganizationPermissions.ManageConnectors,
OrganizationPermissions.AccessOwnedWorkspaces,
],
OrganizationRoles.Member =>
[
OrganizationPermissions.AccessOwnedWorkspaces,
],
_ => [],
};
}
public static bool RoleHasPermission(string role, string permission)
{
return GetPermissionsForRole(role).Contains(permission, StringComparer.Ordinal);
}
}

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.Organizations.Services;
public static class OrganizationPermissions
{
public const string ManageOrganizationSettings = "ManageOrganizationSettings";
public const string ManageOrganizationMembers = "ManageOrganizationMembers";
public const string CreateWorkspaces = "CreateWorkspaces";
public const string ManageWorkspaces = "ManageWorkspaces";
public const string ManageBilling = "ManageBilling";
public const string ManageConnectors = "ManageConnectors";
public const string AccessOwnedWorkspaces = "AccessOwnedWorkspaces";
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Organizations.Services;
public static class OrganizationRoles
{
public const string Owner = "Owner";
public const string Admin = "Admin";
public const string BillingManager = "BillingManager";
public const string ConnectorManager = "ConnectorManager";
public const string Member = "Member";
}

View File

@@ -1,27 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Projects.Data;
public static class ProjectModelConfiguration
{
public static ModelBuilder ConfigureProjectsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>(project =>
{
project.ToTable("Projects");
project.HasKey(x => x.Id);
project.Property(x => x.Name).HasMaxLength(256).IsRequired();
project.Property(x => x.Description).HasMaxLength(4000);
project.Property(x => x.Notes).HasMaxLength(4000);
project.Property(x => x.Status).HasMaxLength(64).IsRequired();
project.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
project.HasIndex(x => new { x.ClientId, x.Name }).IsUnique();
project.HasIndex(x => x.WorkspaceId);
project.HasIndex(x => x.ClientId);
});
return modelBuilder;
}
}

View File

@@ -1,12 +0,0 @@
using Socialize.Api.Modules.Projects.Data;
namespace Socialize.Api.Modules.Projects;
public static class DependencyInjection
{
public static WebApplicationBuilder AddProjectsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -1,89 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Projects.Data;
namespace Socialize.Api.Modules.Projects.Handlers;
public record GetProjectsRequest(Guid? WorkspaceId, Guid? ClientId);
public record ProjectDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
string Name,
string? Description,
string? Notes,
string Status,
DateTimeOffset StartDate,
DateTimeOffset EndDate);
public class GetProjectsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetProjectsRequest, IReadOnlyCollection<ProjectDto>>
{
public override void Configure()
{
Get("/api/projects");
Options(o => o.WithTags("Projects"));
}
public override async Task HandleAsync(GetProjectsRequest request, CancellationToken ct)
{
IQueryable<Project> query = dbContext.Projects.AsQueryable();
if (accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
query = query.Where(project => workspaceScopeIds.Contains(project.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(project => clientScopeIds.Contains(project.ClientId));
}
if (projectScopeIds.Count > 0)
{
query = query.Where(project => projectScopeIds.Contains(project.Id));
}
}
if (request.ClientId.HasValue)
{
query = query.Where(project => project.ClientId == request.ClientId.Value);
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value);
}
List<ProjectDto> projects = await query
.OrderBy(project => project.Name)
.Select(project => new ProjectDto(
project.Id,
project.WorkspaceId,
project.ClientId,
project.Name,
project.Description,
project.Notes,
project.Status,
project.StartDate,
project.EndDate))
.ToListAsync(ct);
await SendOkAsync(projects, ct);
}
}

View File

@@ -4,9 +4,13 @@ public class Workspace
{
public Guid Id { get; init; }
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
public Guid OrganizationId { get; set; }
public Guid OwnerUserId { get; set; }
public required string TimeZone { get; set; }
public string ApprovalMode { get; set; } = "Required";
public bool SchedulePostsAutomaticallyOnApproval { get; set; }
public bool LockContentAfterApproval { get; set; }
public bool SendAutomaticApprovalReminders { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace Socialize.Api.Modules.Workspaces.Data;
public static class WorkspaceInviteStatuses
{
public const string Pending = "Pending";
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.Organizations.Data;
namespace Socialize.Api.Modules.Workspaces.Data;
@@ -11,14 +12,21 @@ public static class WorkspaceModelConfiguration
workspace.ToTable("Workspaces");
workspace.HasKey(x => x.Id);
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required");
workspace.Property(x => x.SchedulePostsAutomaticallyOnApproval).HasDefaultValue(false);
workspace.Property(x => x.LockContentAfterApproval).HasDefaultValue(false);
workspace.Property(x => x.SendAutomaticApprovalReminders).HasDefaultValue(false);
workspace.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workspace.HasIndex(x => x.Slug).IsUnique();
workspace.HasIndex(x => x.OrganizationId);
workspace.HasIndex(x => x.OwnerUserId);
workspace.HasOne<Organization>()
.WithMany()
.HasForeignKey(x => x.OrganizationId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<WorkspaceInvite>(workspaceInvite =>

View File

@@ -47,7 +47,7 @@ public class ChangeWorkspaceLogoHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -7,8 +7,8 @@ using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record CreateWorkspaceRequest(
Guid OrganizationId,
string Name,
string Slug,
string TimeZone);
public class CreateWorkspaceRequestValidator
@@ -16,11 +16,8 @@ public class CreateWorkspaceRequestValidator
{
public CreateWorkspaceRequestValidator()
{
RuleFor(x => x.OrganizationId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug)
.NotEmpty()
.MaximumLength(128)
.Matches("^[a-z0-9]+(?:-[a-z0-9]+)*$");
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
}
}
@@ -38,31 +35,29 @@ public class CreateWorkspaceHandler(
public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct)
{
if (!accessScopeService.IsManager(User))
if (!await accessScopeService.CanCreateWorkspaceAsync(User, request.OrganizationId, ct))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedSlug = request.Slug.Trim().ToLowerInvariant();
string normalizedTimeZone = request.TimeZone.Trim();
bool duplicateWorkspace = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Slug == normalizedSlug, ct);
if (duplicateWorkspace)
bool organizationExists = await dbContext.Organizations
.AnyAsync(organization => organization.Id == request.OrganizationId, ct);
if (!organizationExists)
{
AddError(request => request.Slug, "A workspace with this slug already exists.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
AddError(request => request.OrganizationId, "The selected organization does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedTimeZone = request.TimeZone.Trim();
Workspace workspace = new()
{
Id = Guid.NewGuid(),
OrganizationId = request.OrganizationId,
Name = normalizedName,
Slug = normalizedSlug,
OwnerUserId = User.GetUserId(),
TimeZone = normalizedTimeZone,
CreatedAt = DateTimeOffset.UtcNow,
@@ -71,13 +66,7 @@ public class CreateWorkspaceHandler(
dbContext.Workspaces.Add(workspace);
await dbContext.SaveChangesAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt);
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}

View File

@@ -24,7 +24,7 @@ public class CreateWorkspaceInviteRequestValidator
public CreateWorkspaceInviteRequestValidator()
{
RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress();
RuleFor(x => x.Role).NotEmpty().Must(role => AllowedRoles.Contains(role));
RuleFor(x => x.Role).NotEmpty().Must(role => AllowedRoles.Contains(role)).WithMessage("A valid role should be specified");
}
}
@@ -43,7 +43,7 @@ public class CreateWorkspaceInviteHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -65,7 +65,7 @@ public class CreateWorkspaceInviteHandler(
bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync(
invite => invite.WorkspaceId == workspaceId &&
invite.Email == normalizedEmail &&
invite.Status == "Pending",
invite.Status == WorkspaceInviteStatuses.Pending,
ct);
if (duplicateInvite)
@@ -81,7 +81,7 @@ public class CreateWorkspaceInviteHandler(
WorkspaceId = workspaceId,
Email = normalizedEmail,
Role = normalizedRole,
Status = "Pending",
Status = WorkspaceInviteStatuses.Pending,
InvitedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};

View File

@@ -29,7 +29,7 @@ public class GetWorkspaceInvitesHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -4,6 +4,7 @@ using System.Security.Claims;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
@@ -12,6 +13,7 @@ public record WorkspaceMemberDto(
string DisplayName,
string Email,
string? PortraitUrl,
string RelationshipCategory,
IReadOnlyCollection<string> Roles);
public class GetWorkspaceMembersHandler(
@@ -29,12 +31,20 @@ public class GetWorkspaceMembersHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
string workspaceClaimValue = workspaceId.ToString();
var users = await dbContext.Users
@@ -42,7 +52,11 @@ public class GetWorkspaceMembersHandler(
dbContext.UserClaims.Any(claim =>
claim.UserId == candidate.Id &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue))
claim.ClaimValue == workspaceClaimValue) ||
dbContext.OrganizationMemberships.Any(membership =>
membership.UserId == candidate.Id &&
membership.OrganizationId == workspace.OrganizationId) ||
candidate.Id == workspace.OwnerUserId)
.OrderBy(candidate => candidate.Lastname)
.ThenBy(candidate => candidate.Firstname)
.ThenBy(candidate => candidate.Email)
@@ -70,12 +84,19 @@ public class GetWorkspaceMembersHandler(
.ToArray(),
ct);
HashSet<Guid> organizationMemberUserIds = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == workspace.OrganizationId)
.Select(membership => membership.UserId)
.ToHashSetAsync(ct);
organizationMemberUserIds.Add(workspace.OwnerUserId);
var members = users
.Select(candidate => new WorkspaceMemberDto(
candidate.Id,
BuildDisplayName(candidate),
candidate.Email ?? string.Empty,
candidate.PortraitUrl,
organizationMemberUserIds.Contains(candidate.Id) ? "Organization Member" : "External Collaborator",
rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty<string>()))
.ToList();

View File

@@ -2,17 +2,52 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record ApprovalStepConfigurationDto(
Guid Id,
Guid WorkspaceId,
string Name,
int SortOrder,
string TargetType,
string TargetValue,
int RequiredApproverCount,
DateTimeOffset CreatedAt);
public record WorkspaceDto(
Guid Id,
Guid OrganizationId,
string Name,
string Slug,
string? LogoUrl,
string TimeZone,
DateTimeOffset CreatedAt);
string ApprovalMode,
bool SchedulePostsAutomaticallyOnApproval,
bool LockContentAfterApproval,
bool SendAutomaticApprovalReminders,
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
DateTimeOffset CreatedAt)
{
public static WorkspaceDto FromWorkspace(
Workspace workspace,
IReadOnlyCollection<ApprovalStepConfigurationDto> approvalSteps)
{
return new WorkspaceDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
}
}
internal class GetWorkspacesHandler(
AppDbContext dbContext,
@@ -27,25 +62,48 @@ internal class GetWorkspacesHandler(
public override async Task HandleAsync(CancellationToken ct)
{
var query = dbContext.Workspaces.AsQueryable();
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
var query = dbContext.Workspaces
.Where(workspace => accessibleWorkspaceIds.Contains(workspace.Id));
if (!accessScopeService.IsManager(User))
{
var workspaceScopeIds = User.GetWorkspaceScopeIds();
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
}
var workspaces = await query
var workspaceRows = await query
.OrderBy(workspace => workspace.Name)
.Select(workspace => new WorkspaceDto(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt))
.ToListAsync(ct);
var workspaceIds = workspaceRows.Select(workspace => workspace.Id).ToList();
List<WorkspaceApprovalStepConfiguration> approvalStepRows = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => workspaceIds.Contains(step.WorkspaceId))
.OrderBy(step => step.SortOrder)
.ThenBy(step => step.Name)
.ToListAsync(ct);
var approvalStepsByWorkspaceId = approvalStepRows
.GroupBy(step => step.WorkspaceId)
.ToDictionary(
group => group.Key,
group => (IReadOnlyCollection<ApprovalStepConfigurationDto>)group
.Select(ToApprovalStepConfigurationDto)
.ToArray());
var workspaces = workspaceRows
.Select(workspace => WorkspaceDto.FromWorkspace(
workspace,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>()))
.ToList();
await SendOkAsync(workspaces, ct);
}
public static ApprovalStepConfigurationDto ToApprovalStepConfigurationDto(WorkspaceApprovalStepConfiguration step)
{
return new ApprovalStepConfigurationDto(
step.Id,
step.WorkspaceId,
step.Name,
step.SortOrder,
step.TargetType,
step.TargetValue,
step.RequiredApproverCount,
step.CreatedAt);
}
}

View File

@@ -2,21 +2,52 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record UpdateApprovalStepConfigurationRequest(
string Name,
int SortOrder,
string TargetType,
string TargetValue,
int RequiredApproverCount);
public record UpdateWorkspaceRequest(
string Name,
string TimeZone);
string TimeZone,
string? ApprovalMode,
bool? SchedulePostsAutomaticallyOnApproval,
bool? LockContentAfterApproval,
bool? SendAutomaticApprovalReminders,
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest>? ApprovalSteps);
public class UpdateWorkspaceRequestValidator
: Validator<UpdateWorkspaceRequest>
{
private static readonly string[] AllowedApprovalModes = ["None", "Optional", "Required", "Multi-level"];
public UpdateWorkspaceRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
RuleFor(x => x.ApprovalMode)
.Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim()))
.WithMessage("A valid approval mode should be specified.");
RuleFor(x => x.ApprovalSteps)
.Must(steps => steps is null || steps.Select(step => step.SortOrder).Distinct().Count() == steps.Count)
.WithMessage("Approval step sort orders must be unique.");
RuleForEach(x => x.ApprovalSteps).ChildRules(step =>
{
step.RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
step.RuleFor(x => x.TargetType)
.Must(ApprovalStepConfigurationRules.IsValidTargetType)
.WithMessage("A valid approval step target type should be specified.");
step.RuleFor(x => x.TargetValue).NotEmpty().MaximumLength(128);
step.RuleFor(x => x.RequiredApproverCount).GreaterThanOrEqualTo(1);
});
}
}
@@ -42,25 +73,157 @@ public class UpdateWorkspaceHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;
}
string nextApprovalMode = string.IsNullOrWhiteSpace(request.ApprovalMode)
? workspace.ApprovalMode
: request.ApprovalMode.Trim();
List<UpdateApprovalStepConfigurationRequest>? requestedApprovalSteps = request.ApprovalSteps?.ToList();
if (nextApprovalMode == ApprovalModes.MultiLevel)
{
bool hasConfiguredSteps = requestedApprovalSteps is null
? await dbContext.WorkspaceApprovalStepConfigurations.AnyAsync(step => step.WorkspaceId == workspace.Id, ct)
: requestedApprovalSteps.Count > 0;
if (!hasConfiguredSteps)
{
AddError(request => request.ApprovalSteps, "Multi-level approval requires at least one approval step.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
if (requestedApprovalSteps is not null &&
!await ValidateApprovalStepsAsync(workspace.Id, requestedApprovalSteps, ct))
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
workspace.Name = request.Name.Trim();
workspace.TimeZone = request.TimeZone.Trim();
workspace.ApprovalMode = nextApprovalMode;
workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval;
workspace.LockContentAfterApproval = request.LockContentAfterApproval ?? workspace.LockContentAfterApproval;
workspace.SendAutomaticApprovalReminders = request.SendAutomaticApprovalReminders ?? workspace.SendAutomaticApprovalReminders;
if (requestedApprovalSteps is not null)
{
List<WorkspaceApprovalStepConfiguration> existingSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.ToListAsync(ct);
dbContext.WorkspaceApprovalStepConfigurations.RemoveRange(existingSteps);
List<WorkspaceApprovalStepConfiguration> replacementSteps = requestedApprovalSteps
.OrderBy(step => step.SortOrder)
.Select(step => new WorkspaceApprovalStepConfiguration
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
Name = step.Name.Trim(),
SortOrder = step.SortOrder,
TargetType = step.TargetType.Trim(),
TargetValue = NormalizeTargetValue(step),
RequiredApproverCount = step.RequiredApproverCount,
CreatedAt = DateTimeOffset.UtcNow,
})
.ToList();
dbContext.WorkspaceApprovalStepConfigurations.AddRange(replacementSteps);
}
await dbContext.SaveChangesAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt);
List<ApprovalStepConfigurationDto> approvalSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.OrderBy(step => step.SortOrder)
.ThenBy(step => step.Name)
.Select(step => new ApprovalStepConfigurationDto(
step.Id,
step.WorkspaceId,
step.Name,
step.SortOrder,
step.TargetType,
step.TargetValue,
step.RequiredApproverCount,
step.CreatedAt))
.ToListAsync(ct);
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, approvalSteps);
await SendOkAsync(dto, ct);
}
private async Task<bool> ValidateApprovalStepsAsync(
Guid workspaceId,
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest> steps,
CancellationToken ct)
{
foreach (UpdateApprovalStepConfigurationRequest step in steps)
{
string targetType = step.TargetType.Trim();
string targetValue = step.TargetValue.Trim();
if (targetType == ApprovalStepTargetTypes.Role &&
!ApprovalStepConfigurationRules.IsValidRoleTarget(targetValue))
{
AddError(request => request.ApprovalSteps, $"'{targetValue}' is not a supported approval role target.");
return false;
}
if (targetType == ApprovalStepTargetTypes.Membership &&
!ApprovalStepConfigurationRules.IsValidMembershipTarget(targetValue))
{
AddError(request => request.ApprovalSteps, $"'{targetValue}' is not a supported approval membership target.");
return false;
}
if (targetType == ApprovalStepTargetTypes.Member)
{
IReadOnlyCollection<Guid> memberUserIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue);
if (memberUserIds.Count == 0)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must reference at least one valid user id.");
return false;
}
if (memberUserIds.Count < step.RequiredApproverCount)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must include at least as many members as required approvers.");
return false;
}
string workspaceClaimValue = workspaceId.ToString();
int workspaceMemberCount = await dbContext.UserClaims
.Where(claim => memberUserIds.Contains(claim.UserId) &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue)
.Select(claim => claim.UserId)
.Distinct()
.CountAsync(ct);
if (workspaceMemberCount != memberUserIds.Count)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must reference users with access to the workspace.");
return false;
}
}
}
return true;
}
private static string NormalizeTargetValue(UpdateApprovalStepConfigurationRequest step)
{
string targetValue = step.TargetValue.Trim();
return step.TargetType.Trim() == ApprovalStepTargetTypes.Member
? ApprovalWorkflowRules.FormatMemberTargetValue(ApprovalWorkflowRules.ParseMemberTargetIds(targetValue))
: targetValue;
}
}

View File

@@ -16,7 +16,8 @@ using Socialize.Api.Modules.ContentItems;
using Socialize.Api.Modules.Feedback;
using Socialize.Api.Modules.Identity;
using Socialize.Api.Modules.Notifications;
using Socialize.Api.Modules.Projects;
using Socialize.Api.Modules.Campaigns;
using Socialize.Api.Modules.Organizations;
using Socialize.Api.Modules.Workspaces;
@@ -62,9 +63,10 @@ var postgresConnectionString = builder.Configuration.GetConnectionString("Postgr
builder.Services.AddAppData(postgresConnectionString);
builder.AddInfrastructureModule();
builder.AddIdentityModule();
builder.AddOrganizationsModule();
builder.AddWorkspaceModule();
builder.AddClientsModule();
builder.AddProjectsModule();
builder.AddCampaignsModule();
builder.AddContentItemsModule();
builder.AddAssetsModule();
builder.AddCommentsModule();

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2"/>
</startup>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Build" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Build.Framework" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Build.Utilities.Core" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Build.Tasks.Core" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.IO.Redist" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.1" newVersion="6.0.0.1"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Collections.Immutable" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.0.1.2" newVersion="4.0.1.2"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

Some files were not shown because too many files have changed in this diff Show More