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
This commit is contained in:
2026-05-01 15:58:04 -04:00
118 changed files with 3783 additions and 1205 deletions

View File

@@ -8,7 +8,7 @@ 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.Workspaces.Data;
namespace Socialize.Api.Data;
@@ -20,7 +20,7 @@ public class AppDbContext(
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>();
@@ -43,7 +43,7 @@ public class AppDbContext(
builder.ConfigureWorkspacesModule();
builder.ConfigureClientsModule();
builder.ConfigureProjectsModule();
builder.ConfigureCampaignsModule();
builder.ConfigureContentItemsModule();
builder.ConfigureAssetsModule();
builder.ConfigureCommentsModule();

View File

@@ -10,7 +10,7 @@ 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.Workspaces.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
@@ -22,8 +22,8 @@ public static class DevelopmentSeedExtensions
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 +99,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(
@@ -200,7 +200,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)
@@ -273,9 +273,9 @@ public static class DevelopmentSeedExtensions
WorkspaceId,
cancellationToken);
await UpsertProjectAsync(
await UpsertCampaignAsync(
dbContext,
ScopedProjectId,
ScopedCampaignId,
WorkspaceId,
ScopedClientId,
"Spring Launch",
@@ -285,9 +285,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,7 +303,7 @@ public static class DevelopmentSeedExtensions
ScopedContentItemId,
WorkspaceId,
ScopedClientId,
ScopedProjectId,
ScopedCampaignId,
"Spring launch hero video",
"Fresh seasonal menu launch across Instagram and TikTok.",
"Instagram Reel, TikTok",
@@ -317,7 +317,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 +491,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 +504,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 +532,7 @@ public static class DevelopmentSeedExtensions
Guid id,
Guid workspaceId,
Guid clientId,
Guid projectId,
Guid campaignId,
string title,
string publicationMessage,
string publicationTargets,
@@ -559,7 +559,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

@@ -36,21 +36,21 @@ 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);
}
}

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";
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class RenameProjectsToCampaigns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(
name: "Projects",
newName: "Campaigns");
migrationBuilder.DropPrimaryKey(
name: "PK_Projects",
table: "Campaigns");
migrationBuilder.AddPrimaryKey(
name: "PK_Campaigns",
table: "Campaigns",
column: "Id");
migrationBuilder.RenameIndex(
name: "IX_Projects_WorkspaceId",
table: "Campaigns",
newName: "IX_Campaigns_WorkspaceId");
migrationBuilder.RenameIndex(
name: "IX_Projects_ClientId_Name",
table: "Campaigns",
newName: "IX_Campaigns_ClientId_Name");
migrationBuilder.RenameIndex(
name: "IX_Projects_ClientId",
table: "Campaigns",
newName: "IX_Campaigns_ClientId");
migrationBuilder.RenameColumn(
name: "ProjectName",
table: "FeedbackReports",
newName: "CampaignName");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "FeedbackReports",
newName: "CampaignId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "ContentItems",
newName: "CampaignId");
migrationBuilder.RenameIndex(
name: "IX_ContentItems_ProjectId",
table: "ContentItems",
newName: "IX_ContentItems_CampaignId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_Campaigns",
table: "Campaigns");
migrationBuilder.AddPrimaryKey(
name: "PK_Projects",
table: "Campaigns",
column: "Id");
migrationBuilder.RenameIndex(
name: "IX_Campaigns_WorkspaceId",
table: "Campaigns",
newName: "IX_Projects_WorkspaceId");
migrationBuilder.RenameIndex(
name: "IX_Campaigns_ClientId_Name",
table: "Campaigns",
newName: "IX_Projects_ClientId_Name");
migrationBuilder.RenameIndex(
name: "IX_Campaigns_ClientId",
table: "Campaigns",
newName: "IX_Projects_ClientId");
migrationBuilder.RenameTable(
name: "Campaigns",
newName: "Projects");
migrationBuilder.RenameColumn(
name: "CampaignName",
table: "FeedbackReports",
newName: "ProjectName");
migrationBuilder.RenameColumn(
name: "CampaignId",
table: "FeedbackReports",
newName: "ProjectId");
migrationBuilder.RenameColumn(
name: "CampaignId",
table: "ContentItems",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_ContentItems_CampaignId",
table: "ContentItems",
newName: "IX_ContentItems_ProjectId");
}
}
}

View File

@@ -438,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")
@@ -549,6 +602,9 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
@@ -572,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)
@@ -600,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");
@@ -784,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)");
@@ -821,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)
@@ -1150,59 +1203,6 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Api.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.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")

View File

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

View File

@@ -64,7 +64,7 @@ public class SubmitApprovalDecisionHandler(
}
if (User?.Identity?.IsAuthenticated == true &&
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
{
await SendForbiddenAsync(ct);
return;

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))
!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
{
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 (!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
{
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 (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
{
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,18 +32,18 @@ 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))
{
@@ -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,89 @@
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))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
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

@@ -51,7 +51,7 @@ public class CreateCommentHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
{
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 (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -40,7 +40,7 @@ public class ResolveCommentHandler(
}
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
|| accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId);
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 (!accessScopeService.CanContributeToCampaign(User, request.WorkspaceId, request.ClientId, request.CampaignId))
{
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 (!accessScopeService.CanContributeToCampaign(User, item.WorkspaceId, item.ClientId, item.CampaignId))
{
await SendForbiddenAsync(ct);
return;

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 (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
{
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 (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
{
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,
@@ -41,7 +41,7 @@ public class GetContentItemsHandler(
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
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

@@ -145,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

@@ -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 (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
{
await SendForbiddenAsync(ct);
return;

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

@@ -16,7 +16,7 @@ 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.Workspaces;
@@ -64,7 +64,7 @@ builder.AddInfrastructureModule();
builder.AddIdentityModule();
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>

View File

@@ -0,0 +1,260 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v6.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v6.0": {
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/4.14.0-3.25262.10": {
"dependencies": {
"Microsoft.Build.Locator": "1.6.10",
"Microsoft.CodeAnalysis.NetAnalyzers": "8.0.0-preview.23468.1",
"Microsoft.CodeAnalysis.PerformanceSensitiveAnalyzers": "3.3.4-beta1.22504.1",
"Microsoft.DotNet.XliffTasks": "9.0.0-beta.25255.5",
"Microsoft.VisualStudio.Threading.Analyzers": "17.13.2",
"Newtonsoft.Json": "13.0.3",
"Roslyn.Diagnostics.Analyzers": "3.11.0-beta1.24081.1",
"System.Collections.Immutable": "9.0.0",
"System.CommandLine": "2.0.0-beta4.24528.1"
},
"runtime": {
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll": {}
},
"resources": {
"cs/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "cs"
},
"de/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "de"
},
"es/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "es"
},
"fr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "fr"
},
"it/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "it"
},
"ja/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "ja"
},
"ko/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "ko"
},
"pl/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "pl"
},
"pt-BR/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "pt-BR"
},
"ru/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "ru"
},
"tr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "tr"
},
"zh-Hans/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "zh-Hans"
},
"zh-Hant/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "zh-Hant"
}
}
},
"Microsoft.Build.Locator/1.6.10": {
"runtime": {
"lib/net6.0/Microsoft.Build.Locator.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.6.10.57384"
}
}
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers/3.11.0-beta1.24081.1": {},
"Microsoft.CodeAnalysis.NetAnalyzers/8.0.0-preview.23468.1": {},
"Microsoft.CodeAnalysis.PerformanceSensitiveAnalyzers/3.3.4-beta1.22504.1": {},
"Microsoft.CodeAnalysis.PublicApiAnalyzers/3.11.0-beta1.24081.1": {},
"Microsoft.DotNet.XliffTasks/9.0.0-beta.25255.5": {},
"Microsoft.VisualStudio.Threading.Analyzers/17.13.2": {},
"Newtonsoft.Json/13.0.3": {
"runtime": {
"lib/net6.0/Newtonsoft.Json.dll": {
"assemblyVersion": "13.0.0.0",
"fileVersion": "13.0.3.27908"
}
}
},
"Roslyn.Diagnostics.Analyzers/3.11.0-beta1.24081.1": {
"dependencies": {
"Microsoft.CodeAnalysis.BannedApiAnalyzers": "3.11.0-beta1.24081.1",
"Microsoft.CodeAnalysis.PublicApiAnalyzers": "3.11.0-beta1.24081.1"
}
},
"System.Collections.Immutable/9.0.0": {
"dependencies": {
"System.Memory": "4.5.5",
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
},
"runtime": {
"lib/netstandard2.0/System.Collections.Immutable.dll": {
"assemblyVersion": "9.0.0.0",
"fileVersion": "9.0.24.52809"
}
}
},
"System.CommandLine/2.0.0-beta4.24528.1": {
"dependencies": {
"System.Memory": "4.5.5"
},
"runtime": {
"lib/netstandard2.0/System.CommandLine.dll": {
"assemblyVersion": "2.0.0.0",
"fileVersion": "2.0.24.52801"
}
},
"resources": {
"lib/netstandard2.0/cs/System.CommandLine.resources.dll": {
"locale": "cs"
},
"lib/netstandard2.0/de/System.CommandLine.resources.dll": {
"locale": "de"
},
"lib/netstandard2.0/es/System.CommandLine.resources.dll": {
"locale": "es"
},
"lib/netstandard2.0/fr/System.CommandLine.resources.dll": {
"locale": "fr"
},
"lib/netstandard2.0/it/System.CommandLine.resources.dll": {
"locale": "it"
},
"lib/netstandard2.0/ja/System.CommandLine.resources.dll": {
"locale": "ja"
},
"lib/netstandard2.0/ko/System.CommandLine.resources.dll": {
"locale": "ko"
},
"lib/netstandard2.0/pl/System.CommandLine.resources.dll": {
"locale": "pl"
},
"lib/netstandard2.0/pt-BR/System.CommandLine.resources.dll": {
"locale": "pt-BR"
},
"lib/netstandard2.0/ru/System.CommandLine.resources.dll": {
"locale": "ru"
},
"lib/netstandard2.0/tr/System.CommandLine.resources.dll": {
"locale": "tr"
},
"lib/netstandard2.0/zh-Hans/System.CommandLine.resources.dll": {
"locale": "zh-Hans"
},
"lib/netstandard2.0/zh-Hant/System.CommandLine.resources.dll": {
"locale": "zh-Hant"
}
}
},
"System.Memory/4.5.5": {},
"System.Runtime.CompilerServices.Unsafe/6.0.0": {}
}
},
"libraries": {
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/4.14.0-3.25262.10": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Microsoft.Build.Locator/1.6.10": {
"type": "package",
"serviceable": true,
"sha512": "sha512-DJhCkTGqy1LMJzEmG/2qxRTMHwdPc3WdVoGQI5o5mKHVo4dsHrCMLIyruwU/NSvPNSdvONlaf7jdFXnAMuxAuA==",
"path": "microsoft.build.locator/1.6.10",
"hashPath": "microsoft.build.locator.1.6.10.nupkg.sha512"
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers/3.11.0-beta1.24081.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-DH6L3rsbjppLrHM2l2/NKbnMaYd0NFHx2pjZaFdrVcRkONrV3i9FHv6Id8Dp6/TmjhXQsJVJJFbhhjkpuP1xxg==",
"path": "microsoft.codeanalysis.bannedapianalyzers/3.11.0-beta1.24081.1",
"hashPath": "microsoft.codeanalysis.bannedapianalyzers.3.11.0-beta1.24081.1.nupkg.sha512"
},
"Microsoft.CodeAnalysis.NetAnalyzers/8.0.0-preview.23468.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ZhIvyxmUCqb8OiU/VQfxfuAmIB4lQsjqhMVYKeoyxzSI+d7uR5Pzx3ZKoaIhPizQ15wa4lnyD6wg3TnSJ6P4LA==",
"path": "microsoft.codeanalysis.netanalyzers/8.0.0-preview.23468.1",
"hashPath": "microsoft.codeanalysis.netanalyzers.8.0.0-preview.23468.1.nupkg.sha512"
},
"Microsoft.CodeAnalysis.PerformanceSensitiveAnalyzers/3.3.4-beta1.22504.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-2XRlqPAzVke7Sb80+UqaC7o57OwfK+tIr+aIOxrx41RWDMeR2SBUW7kL4sd6hfLFfBNsLo3W5PT+UwfvwPaOzA==",
"path": "microsoft.codeanalysis.performancesensitiveanalyzers/3.3.4-beta1.22504.1",
"hashPath": "microsoft.codeanalysis.performancesensitiveanalyzers.3.3.4-beta1.22504.1.nupkg.sha512"
},
"Microsoft.CodeAnalysis.PublicApiAnalyzers/3.11.0-beta1.24081.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-3bYGBihvoNO0rhCOG1U9O50/4Q8suZ+glHqQLIAcKvnodSnSW+dYWYzTNb1UbS8pUS8nAUfxSFMwuMup/G5DtQ==",
"path": "microsoft.codeanalysis.publicapianalyzers/3.11.0-beta1.24081.1",
"hashPath": "microsoft.codeanalysis.publicapianalyzers.3.11.0-beta1.24081.1.nupkg.sha512"
},
"Microsoft.DotNet.XliffTasks/9.0.0-beta.25255.5": {
"type": "package",
"serviceable": true,
"sha512": "sha512-bb0fZB5ViPscdfYeWlmtyXJMzNkgcpkV5RWmXktfV9lwIUZgNZmFotUXrdcTyZzrN7v1tQK/Y6BGnbkP9gEsXg==",
"path": "microsoft.dotnet.xlifftasks/9.0.0-beta.25255.5",
"hashPath": "microsoft.dotnet.xlifftasks.9.0.0-beta.25255.5.nupkg.sha512"
},
"Microsoft.VisualStudio.Threading.Analyzers/17.13.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Qcd8IlaTXZVq3wolBnzby1P7kWihdWaExtD8riumiKuG1sHa8EgjV/o70TMjTaeUMhomBbhfdC9OPwAHoZfnjQ==",
"path": "microsoft.visualstudio.threading.analyzers/17.13.2",
"hashPath": "microsoft.visualstudio.threading.analyzers.17.13.2.nupkg.sha512"
},
"Newtonsoft.Json/13.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==",
"path": "newtonsoft.json/13.0.3",
"hashPath": "newtonsoft.json.13.0.3.nupkg.sha512"
},
"Roslyn.Diagnostics.Analyzers/3.11.0-beta1.24081.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-reHqZCDKifA+DURcL8jUfYkMGL4FpgNt5LI0uWTS6IpM8kKVbu/kO8byZsqfhBu4wUzT3MBDcoMfzhZPdENIpg==",
"path": "roslyn.diagnostics.analyzers/3.11.0-beta1.24081.1",
"hashPath": "roslyn.diagnostics.analyzers.3.11.0-beta1.24081.1.nupkg.sha512"
},
"System.Collections.Immutable/9.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==",
"path": "system.collections.immutable/9.0.0",
"hashPath": "system.collections.immutable.9.0.0.nupkg.sha512"
},
"System.CommandLine/2.0.0-beta4.24528.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Xt8tsSU8yd0ZpbT9gl5DAwkMYWLo8PV1fq2R/belrUbHVVOIKqhLfbWksbdknUDpmzMHZenBtD6AGAp9uJTa2w==",
"path": "system.commandline/2.0.0-beta4.24528.1",
"hashPath": "system.commandline.2.0.0-beta4.24528.1.nupkg.sha512"
},
"System.Memory/4.5.5": {
"type": "package",
"serviceable": true,
"sha512": "sha512-XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==",
"path": "system.memory/4.5.5",
"hashPath": "system.memory.4.5.5.nupkg.sha512"
},
"System.Runtime.CompilerServices.Unsafe/6.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==",
"path": "system.runtime.compilerservices.unsafe/6.0.0",
"hashPath": "system.runtime.compilerservices.unsafe.6.0.0.nupkg.sha512"
}
}
}

View File

@@ -0,0 +1,659 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<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="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="Microsoft.VisualBasic.Core" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-11.0.0.0" newVersion="11.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Win32.Primitives" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Win32.Registry" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Collections.Concurrent" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Collections.NonGeneric" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Collections.Specialized" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Collections" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.ComponentModel.Annotations" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.ComponentModel.EventBasedAsync" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.ComponentModel.Primitives" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.ComponentModel.TypeConverter" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.ComponentModel" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Console" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Data.Common" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.Contracts" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.FileVersionInfo" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.Process" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.StackTrace" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.TextWriterTraceListener" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.TraceSource" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.Tracing" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Drawing.Primitives" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.Compression.ZipFile" publicKeyToken="b77a5c561934e089"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.Compression" publicKeyToken="b77a5c561934e089" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.FileSystem.AccessControl" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.FileSystem.DriveInfo" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.FileSystem.Watcher" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.IsolatedStorage" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.MemoryMappedFiles" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.Pipes.AccessControl" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.IO.Pipes" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Linq.Expressions" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Linq.Parallel" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Linq.Queryable" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Linq" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.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-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.HttpListener" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.Mail" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.NameResolution" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.NetworkInformation" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.Ping" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.Primitives" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.Requests" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.Security" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.ServicePoint" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.Sockets" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.WebClient" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.WebHeaderCollection" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.WebProxy" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.WebSockets.Client" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Net.WebSockets" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Numerics.Vectors" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.ObjectModel" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Reflection.Emit.ILGeneration" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Reflection.Emit.Lightweight" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Reflection.Emit" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Reflection.Primitives" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Resources.Writer" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.VisualC" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.InteropServices.RuntimeInformation"
publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.InteropServices" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.Numerics" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.Serialization.Formatters" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.Serialization.Json" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.Serialization.Primitives" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.Serialization.Xml" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.AccessControl" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Claims" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Cryptography.Algorithms" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Cryptography.Cng" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Cryptography.Csp" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Cryptography.Encoding" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Cryptography.Primitives" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Cryptography.X509Certificates" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Principal.Windows" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Text.Encoding.Extensions" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Text.RegularExpressions" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading.Overlapped" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading.Tasks.Parallel" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading.Thread" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading.ThreadPool" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Transactions.Local" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Web.HttpUtility" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Xml.ReaderWriter" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Xml.XDocument" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Xml.XPath.XDocument" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Xml.XPath" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Xml.XmlSerializer" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="netstandard" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-2.1.0.0" newVersion="2.1.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Configuration.ConfigurationManager" publicKeyToken="cc7b13ffcd2ddd51"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Security.Cryptography.Xml" publicKeyToken="cc7b13ffcd2ddd51"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.CodeDom" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

View File

@@ -0,0 +1,13 @@
{
"runtimeOptions": {
"tfm": "net6.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "6.0.0"
},
"rollForward": "Major",
"configProperties": {
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false
}
}
}

View File

@@ -31,7 +31,7 @@ Composition registers:
- web services and auth in `DependencyInjection.cs`
- infrastructure in `Infrastructure/DependencyInjection.cs`
- domain modules for Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications, and Feedback
- domain modules for Identity, Workspaces, Clients, Campaigns, ContentItems, Assets, Comments, Approvals, Notifications, and Feedback
## Data Ownership

View File

@@ -100,7 +100,7 @@ Each report should capture useful debugging context automatically when available
- current app URL/path
- active workspace id/name
- active client id/name
- active project id/name
- active campaign id/name
- active content item id/title
- browser user agent
- viewport size

View File

@@ -0,0 +1,25 @@
# Rename Projects To Campaigns
## Goal
Align the codebase terminology with the product language by replacing the `Project` domain surface with `Campaign`.
## Relevant Specs
- `docs/product/glossary.md`
- `docs/ARCHITECTURE.md`
## Scope
- Rename backend module, entity, DTOs, handlers, EF configuration, and route/tag names from projects to campaigns.
- Update content item and access-scope references that point at the renamed campaign concept.
- Update frontend feature naming and API calls where they still refer to projects.
- Update OpenAPI snapshots if backend contracts change and the backend can run.
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend && npm run build
```

View File

@@ -17,7 +17,7 @@ Add the backend foundation for product feedback reports.
- type: `Bug`, `Suggestion`, `Request`
- status: `New`, `Planned`, `Resolved`, `Won't Do`, `Cancelled`
- Add `DbSet` entries and module configuration to `AppDbContext`.
- Capture reporter id, reporter display fields, submitted route, browser metadata, viewport size, app version if available, and optional workspace/client/project/content context.
- Capture reporter id, reporter display fields, submitted route, browser metadata, viewport size, app version if available, and optional workspace/client/campaign/content context.
- Add API endpoints for:
- submit feedback
- list current user's feedback

View File

@@ -29,6 +29,10 @@ An agency is above the workspace level in the business model.
Business, brand, or organization represented by a workspace and participating in review and approval flows.
## Campaign
Client-owned body of content work that groups related content items, notes, and timelines.
## Content Item
Primary reviewable unit in the system. Contains metadata, copy, due dates, networks, channels, and linked assets.

View File

@@ -1,4 +1,4 @@
VITE_API_URL=http://192.168.1.2:5080
VITE_API_URL=http://192.168.1.17:5080
VITE_STRIPE_API_KEY=pk_test_51OoveVDrRyqXtNdB2st1NgA8WQA9rhgGaf3q7bCpAOoQyyRS30HMCzGeHba7meVGCSPfb1BVWmOTmFOcr9MkKf5H00bLu5MqsS
VITE_GOOGLE_CLIENT_ID=213344094492-9dbaet2gaschju3hj1sgv1umk0qpd833.apps.googleusercontent.com
VITE_FACEBOOK_APP_ID=1076433907621883

View File

@@ -0,0 +1,16 @@
# Claims and Roles Guidelines
To ensure consistency across the application, all claim and role values MUST be in lowercase.
## Roles
The following roles are currently used in the system:
- `administrator`
- `manager`
- `client`
- `provider`
- `developer`
## Implementation Notes
- **Processing**: The `authStore.js` automatically converts all roles extracted from JWT tokens to lowercase.
- **Comparisons**: All checks (e.g., `authStore.hasAnyRole(['role-name'])` or `meta: { roles: ['role-name'] }`) should use lowercase strings.
- **Routing**: Route guards in `router.js` expect lowercase role names in the `meta.roles` field.

View File

@@ -100,22 +100,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesProjectsHandlersGetProjectsHandler"];
put?: never;
post: operations["SocializeApiModulesProjectsHandlersCreateProjectHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/notifications": {
parameters: {
query?: never;
@@ -772,6 +756,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/campaigns": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesCampaignsHandlersGetCampaignsHandler"];
put?: never;
post: operations["SocializeApiModulesCampaignsHandlersCreateCampaignHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/assets/{id}/revisions": {
parameters: {
query?: never;
@@ -952,36 +952,6 @@ export interface components {
/** Format: int32 */
requiredApproverCount?: number;
};
SocializeApiModulesProjectsHandlersProjectDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
/** Format: guid */
clientId?: string;
name?: string;
description?: string | null;
notes?: string | null;
status?: string;
/** Format: date-time */
startDate?: string;
/** Format: date-time */
endDate?: string;
};
SocializeApiModulesProjectsHandlersCreateProjectRequest: {
/** Format: guid */
workspaceId: string;
/** Format: guid */
clientId: string;
name: string;
/** Format: date-time */
startDate: string;
/** Format: date-time */
endDate: string;
description?: string | null;
notes?: string | null;
};
SocializeApiModulesProjectsHandlersGetProjectsRequest: Record<string, never>;
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
/** Format: guid */
id?: string;
@@ -1041,7 +1011,7 @@ export interface components {
persona?: string | null;
authorizedWorkspaceIds?: string[];
authorizedClientIds?: string[];
authorizedProjectIds?: string[];
authorizedCampaignIds?: string[];
username?: string;
alias?: string | null;
portraitUrl?: string | null;
@@ -1176,8 +1146,8 @@ export interface components {
clientId?: string | null;
clientName?: string | null;
/** Format: guid */
projectId?: string | null;
projectName?: string | null;
campaignId?: string | null;
campaignName?: string | null;
/** Format: guid */
contentItemId?: string | null;
contentItemTitle?: string | null;
@@ -1217,8 +1187,8 @@ export interface components {
clientId?: string | null;
clientName?: string | null;
/** Format: guid */
projectId?: string | null;
projectName?: string | null;
campaignId?: string | null;
campaignName?: string | null;
/** Format: guid */
contentItemId?: string | null;
contentItemTitle?: string | null;
@@ -1236,7 +1206,7 @@ export interface components {
/** Format: guid */
clientId?: string;
/** Format: guid */
projectId?: string;
campaignId?: string;
title?: string;
publicationMessage?: string;
publicationTargets?: string;
@@ -1254,7 +1224,7 @@ export interface components {
/** Format: guid */
clientId: string;
/** Format: guid */
projectId: string;
campaignId: string;
title: string;
publicationMessage: string;
publicationTargets: string;
@@ -1295,7 +1265,7 @@ export interface components {
/** Format: guid */
clientId?: string;
/** Format: guid */
projectId?: string;
campaignId?: string;
title?: string;
publicationMessage?: string;
publicationTargets?: string;
@@ -1383,6 +1353,36 @@ export interface components {
primaryContactEmail?: string | null;
primaryContactPortraitUrl?: string | null;
};
SocializeApiModulesCampaignsHandlersCampaignDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
/** Format: guid */
clientId?: string;
name?: string;
description?: string | null;
notes?: string | null;
status?: string;
/** Format: date-time */
startDate?: string;
/** Format: date-time */
endDate?: string;
};
SocializeApiModulesCampaignsHandlersCreateCampaignRequest: {
/** Format: guid */
workspaceId: string;
/** Format: guid */
clientId: string;
name: string;
/** Format: date-time */
startDate: string;
/** Format: date-time */
endDate: string;
description?: string | null;
notes?: string | null;
};
SocializeApiModulesCampaignsHandlersGetCampaignsRequest: Record<string, never>;
SocializeApiModulesAssetsHandlersAssetRevisionDto: {
/** Format: guid */
id?: string;
@@ -1778,76 +1778,6 @@ export interface operations {
};
};
};
SocializeApiModulesProjectsHandlersGetProjectsHandler: {
parameters: {
query?: {
workspaceId?: string | null;
clientId?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesProjectsHandlersProjectDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesProjectsHandlersCreateProjectHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesProjectsHandlersCreateProjectRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesProjectsHandlersProjectDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
parameters: {
query?: {
@@ -2936,7 +2866,7 @@ export interface operations {
query?: {
workspaceId?: string | null;
clientId?: string | null;
projectId?: string | null;
campaignId?: string | null;
};
header?: never;
path?: never;
@@ -3395,6 +3325,76 @@ export interface operations {
};
};
};
SocializeApiModulesCampaignsHandlersGetCampaignsHandler: {
parameters: {
query?: {
workspaceId?: string | null;
clientId?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCampaignDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCampaignsHandlersCreateCampaignHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCreateCampaignRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCampaignDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesAssetsHandlersCreateAssetRevisionHandler: {
parameters: {
query?: never;

View File

@@ -7,294 +7,295 @@ import { jwtDecode } from 'jwt-decode';
import { formatDuration } from '@/internal_time_ago.js';
export const useAuthStore = defineStore('auth', () => {
const clientApi = useClient();
const router = useRouter();
const clientApi = useClient();
const router = useRouter();
const isRefreshing = ref(false);
let refreshPromise = null;
const isRefreshing = ref(false);
let refreshPromise = null;
const accessToken = useSessionStorage('auth-accessToken', undefined);
const refreshToken = useSessionStorage('auth-refreshToken', undefined);
const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
serializer: {
read: v => (v ? JSON.parse(v) : null),
write: v => (v ? JSON.stringify(v) : null),
},
});
const accessToken = useSessionStorage('auth-accessToken', undefined);
const refreshToken = useSessionStorage('auth-refreshToken', undefined);
const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
serializer: {
read: v => (v ? JSON.parse(v) : null),
write: v => (v ? JSON.stringify(v) : null),
},
});
const isAuthenticated = computed(() => !!accessToken.value);
const userId = computed(() => tokenClaims.value?.sub);
const userRoles = computed(() => {
const claims = tokenClaims.value ?? {};
const candidates = [
claims.role,
claims.roles,
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'],
].flatMap(value => Array.isArray(value) ? value : value ? [value] : []);
const isAuthenticated = computed(() => !!accessToken.value);
const userId = computed(() => tokenClaims.value?.sub);
const userRoles = computed(() => {
const claims = tokenClaims.value ?? {};
const candidates = [
claims.role,
claims.roles,
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'],
].flatMap(value => Array.isArray(value) ? value : value ? [value] : [])
.map(v => v.toLowerCase());
return [...new Set(candidates)];
});
const persona = computed(() => tokenClaims.value?.persona ?? null);
const isManager = computed(() => userRoles.value.includes('Administrator') || userRoles.value.includes('Manager'));
const isClient = computed(() => userRoles.value.includes('Client'));
const isProvider = computed(() => userRoles.value.includes('Provider'));
return [...new Set(candidates)];
});
const persona = computed(() => tokenClaims.value?.persona ?? null);
const isManager = computed(() => userRoles.value.includes('administrator') || userRoles.value.includes('manager'));
const isClient = computed(() => userRoles.value.includes('client'));
const isProvider = computed(() => userRoles.value.includes('provider'));
function updateTokens(data) {
if (!data?.accessToken || !data?.refreshToken) {
throw new Error('Invalid token data');
}
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
const claims = getClaimsFromToken(data.accessToken);
tokenClaims.value = claims;
console.log('Tokens updated, user ID:', claims?.sub);
function updateTokens(data) {
if (!data?.accessToken || !data?.refreshToken) {
throw new Error('Invalid token data');
}
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
const claims = getClaimsFromToken(data.accessToken);
tokenClaims.value = claims;
console.log('Tokens updated, user ID:', claims?.sub);
}
function cleanTokens() {
console.log('cleanTokens called - clearing stored tokens');
accessToken.value = undefined;
refreshToken.value = undefined;
tokenClaims.value = null;
}
async function logout() {
cleanTokens();
await router.push('/');
}
async function login(email, password) {
console.log('login called with email:', email);
if (!email || !password) {
throw new Error('Email and password are required');
}
function cleanTokens() {
console.log('cleanTokens called - clearing stored tokens');
accessToken.value = undefined;
refreshToken.value = undefined;
tokenClaims.value = null;
try {
const response = await clientApi.post('api/users/login', {
email: email.trim(),
password: password,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid login response');
}
updateTokens(response.data);
console.log('login successful');
return true;
} catch (error) {
console.error('Login failed:', error);
cleanTokens();
throw error;
}
}
async function loginWithGoogle(accessTokenParam) {
console.log('loginWithGoogle called');
if (!accessTokenParam) {
throw new Error('Google access token is required');
}
async function logout() {
cleanTokens();
await router.push('/');
try {
const response = await clientApi.post('api/users/login-with-google', {
token: accessTokenParam,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Google login response');
}
updateTokens(response.data);
console.log('Google login successful');
return true;
} catch (error) {
console.error('Google login failed:', error);
cleanTokens();
throw error;
}
}
async function loginWithFacebook(authResponse) {
console.log('loginWithFacebook called');
if (!authResponse?.accessToken) {
throw new Error('Facebook access token is required');
}
async function login(email, password) {
console.log('login called with email:', email);
if (!email || !password) {
throw new Error('Email and password are required');
}
try {
const response = await clientApi.post('api/users/login-with-facebook', {
token: authResponse.accessToken,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Facebook login response');
}
updateTokens(response.data);
console.log('Facebook login successful');
return true;
} catch (error) {
console.error('Facebook login failed:', error);
cleanTokens();
throw error;
}
}
async function refresh() {
console.log('refresh called');
if (!refreshToken.value) {
cleanTokens(); // Clear tokens first
throw new Error('No refresh token available');
}
if (isRefreshing.value && refreshPromise) {
console.log('Already refreshing, returning existing refreshPromise');
return refreshPromise;
}
try {
isRefreshing.value = true;
refreshPromise = (async () => {
try {
const response = await clientApi.post('api/users/login', {
email: email.trim(),
password: password,
console.log('Sending refresh request...');
const response = await clientApi.post('api/users/refresh', {
refreshToken: refreshToken.value,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid refresh response');
}
updateTokens({
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
});
console.log('Token refresh successful');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
cleanTokens();
const currentRoute = router.currentRoute.value;
const returnUrl = currentRoute.fullPath;
// Handle navigation
router
.push({
name: 'login',
query: { returnUrl },
})
.catch(navError => {
console.error('Navigation error after token refresh failure:', navError);
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid login response');
}
updateTokens(response.data);
console.log('login successful');
return true;
} catch (error) {
console.error('Login failed:', error);
cleanTokens();
throw error;
throw error; // Re-throw to notify callers
}
})();
return await refreshPromise;
} catch (error) {
throw error;
} finally {
// Ensure these are always reset, even if an error is thrown
isRefreshing.value = false;
refreshPromise = null;
}
}
function getClaimsFromToken(token) {
if (!token) return null;
try {
return jwtDecode(token);
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
}
function isTokenExpiringSoon(token) {
if (!token) {
console.log('No token provided, considered expiring soon');
return true;
}
async function loginWithGoogle(accessTokenParam) {
console.log('loginWithGoogle called');
if (!accessTokenParam) {
throw new Error('Google access token is required');
}
try {
const response = await clientApi.post('api/users/login-with-google', {
token: accessTokenParam,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Google login response');
}
updateTokens(response.data);
console.log('Google login successful');
return true;
} catch (error) {
console.error('Google login failed:', error);
cleanTokens();
throw error;
}
const claims = getClaimsFromToken(token);
if (!claims || !claims.exp) {
console.log('No valid claims found, considered expiring soon');
return true;
}
async function loginWithFacebook(authResponse) {
console.log('loginWithFacebook called');
if (!authResponse?.accessToken) {
throw new Error('Facebook access token is required');
}
const expirationTime = claims.exp * 1000; // Convert to milliseconds
const currentTime = Date.now();
const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration
try {
const response = await clientApi.post('api/users/login-with-facebook', {
token: authResponse.accessToken,
});
// Calculate time remaining (can be negative if already expired)
const timeRemainingMs = expirationTime - currentTime;
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Facebook login response');
}
// Token is expiring soon if less than 2 minutes remaining or already expired
const isExpiring = timeRemainingMs < fiveMinutesInMs;
updateTokens(response.data);
console.log('Facebook login successful');
return true;
} catch (error) {
console.error('Facebook login failed:', error);
cleanTokens();
throw error;
}
// Determine the sign for display purposes
const formattedTimeRemaining =
timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs);
if (isExpiring) {
console.log(`Token expiration check; is token expired: ${isExpiring}`, {
expirationTime: new Date(expirationTime).toLocaleString(),
currentTime: new Date(currentTime).toLocaleString(),
timeRemaining: formattedTimeRemaining,
});
}
async function refresh() {
console.log('refresh called');
return isExpiring;
}
if (!refreshToken.value) {
cleanTokens(); // Clear tokens first
throw new Error('No refresh token available');
}
if (isRefreshing.value && refreshPromise) {
console.log('Already refreshing, returning existing refreshPromise');
return refreshPromise;
}
try {
isRefreshing.value = true;
refreshPromise = (async () => {
try {
console.log('Sending refresh request...');
const response = await clientApi.post('api/users/refresh', {
refreshToken: refreshToken.value,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid refresh response');
}
updateTokens({
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
});
console.log('Token refresh successful');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
cleanTokens();
const currentRoute = router.currentRoute.value;
const returnUrl = currentRoute.fullPath;
// Handle navigation
router
.push({
name: 'login',
query: { returnUrl },
})
.catch(navError => {
console.error('Navigation error after token refresh failure:', navError);
});
throw error; // Re-throw to notify callers
}
})();
return await refreshPromise;
} catch (error) {
throw error;
} finally {
// Ensure these are always reset, even if an error is thrown
isRefreshing.value = false;
refreshPromise = null;
}
async function changePassword(newPassword) {
console.log('changePassword called');
if (!isAuthenticated.value) {
throw new Error('User must be authenticated to change password');
}
function getClaimsFromToken(token) {
if (!token) return null;
try {
return jwtDecode(token);
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
if (!newPassword) {
throw new Error('New password is required');
}
function isTokenExpiringSoon(token) {
if (!token) {
console.log('No token provided, considered expiring soon');
return true;
}
try {
const response = await clientApi.post('api/users/set-password', {
newPassword,
});
const claims = getClaimsFromToken(token);
if (!claims || !claims.exp) {
console.log('No valid claims found, considered expiring soon');
return true;
}
const expirationTime = claims.exp * 1000; // Convert to milliseconds
const currentTime = Date.now();
const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration
// Calculate time remaining (can be negative if already expired)
const timeRemainingMs = expirationTime - currentTime;
// Token is expiring soon if less than 2 minutes remaining or already expired
const isExpiring = timeRemainingMs < fiveMinutesInMs;
// Determine the sign for display purposes
const formattedTimeRemaining =
timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs);
if (isExpiring) {
console.log(`Token expiration check; is token expired: ${isExpiring}`, {
expirationTime: new Date(expirationTime).toLocaleString(),
currentTime: new Date(currentTime).toLocaleString(),
timeRemaining: formattedTimeRemaining,
});
}
return isExpiring;
console.log('Password changed successfully');
return true;
} catch (error) {
console.error('Password change failed:', error);
throw error;
}
}
async function changePassword(newPassword) {
console.log('changePassword called');
if (!isAuthenticated.value) {
throw new Error('User must be authenticated to change password');
}
function hasAnyRole(roles) {
return roles.some(role => userRoles.value.includes(role));
}
if (!newPassword) {
throw new Error('New password is required');
}
try {
const response = await clientApi.post('api/users/set-password', {
newPassword,
});
console.log('Password changed successfully');
return true;
} catch (error) {
console.error('Password change failed:', error);
throw error;
}
}
function hasAnyRole(roles) {
return roles.some(role => userRoles.value.includes(role));
}
return {
accessToken,
refreshToken,
isAuthenticated,
userId,
userRoles,
persona,
hasAnyRole,
isManager,
isClient,
isProvider,
isRefreshing,
login,
loginWithGoogle,
loginWithFacebook,
logout,
refresh,
isTokenExpiringSoon,
changePassword,
};
return {
accessToken,
refreshToken,
isAuthenticated,
userId,
userRoles,
persona,
hasAnyRole,
isManager,
isClient,
isProvider,
isRefreshing,
login,
loginWithGoogle,
loginWithFacebook,
logout,
refresh,
isTokenExpiringSoon,
changePassword,
};
});

View File

@@ -4,19 +4,19 @@ import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useProjectsStore = defineStore('projects', () => {
export const useCampaignsStore = defineStore('campaigns', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const projects = ref([]);
const campaigns = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const error = ref(null);
async function fetchProjects() {
async function fetchCampaigns() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
projects.value = [];
campaigns.value = [];
error.value = null;
return;
}
@@ -25,49 +25,49 @@ export const useProjectsStore = defineStore('projects', () => {
error.value = null;
try {
const response = await client.get('/api/projects', {
const response = await client.get('/api/campaigns', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
projects.value = response.data ?? [];
campaigns.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch projects:', fetchError);
projects.value = [];
error.value = 'Failed to load projects.';
console.error('Failed to fetch campaigns:', fetchError);
campaigns.value = [];
error.value = 'Failed to load campaigns.';
} finally {
isLoading.value = false;
}
}
async function createProject(payload) {
async function createCampaign(payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to create a project.');
throw new Error('You must be authenticated to create a campaign.');
}
if (isCreating.value) {
throw new Error('A project creation request is already in progress.');
throw new Error('A campaign creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/projects', {
const response = await client.post('/api/campaigns', {
...payload,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
projects.value = [...projects.value, response.data]
campaigns.value = [...campaigns.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (createError) {
console.error('Failed to create project:', createError);
error.value = 'Failed to create project.';
console.error('Failed to create campaign:', createError);
error.value = 'Failed to create campaign.';
throw createError;
} finally {
isCreating.value = false;
@@ -78,22 +78,22 @@ export const useProjectsStore = defineStore('projects', () => {
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
projects.value = [];
campaigns.value = [];
error.value = null;
return;
}
await fetchProjects();
await fetchCampaigns();
},
{ immediate: true }
);
return {
projects,
campaigns,
isLoading,
isCreating,
error,
fetchProjects,
createProject,
fetchCampaigns,
createCampaign,
};
});

View File

@@ -3,22 +3,22 @@
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const authStore = useAuthStore();
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore();
const project = computed(() =>
projectsStore.projects.find(candidate => candidate.id === route.params.projectId) ?? null
const campaign = computed(() =>
campaignsStore.campaigns.find(candidate => candidate.id === route.params.campaignId) ?? null
);
const scopedItems = computed(() =>
contentItemsStore.items
.filter(item => item.projectId === route.params.projectId)
.filter(item => item.campaignId === route.params.campaignId)
.sort((left, right) => {
const leftDue = left.dueDate ? new Date(left.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
const rightDue = right.dueDate ? new Date(right.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
@@ -26,8 +26,8 @@
})
);
function formatProjectDateRange(projectValue) {
if (!projectValue?.startDate || !projectValue?.endDate) {
function formatCampaignDateRange(campaignValue) {
if (!campaignValue?.startDate || !campaignValue?.endDate) {
return 'No date range';
}
@@ -35,14 +35,14 @@
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(new Date(projectValue.startDate), new Date(projectValue.endDate));
}).formatRange(new Date(campaignValue.startDate), new Date(campaignValue.endDate));
}
</script>
<template>
<section class="page-shell">
<div
v-if="!project"
v-if="!campaign"
class="page-message error"
>
The selected campaign could not be found in the active workspace.
@@ -66,21 +66,21 @@
Campaigns
</router-link>
</div>
<h1>{{ project.name }}</h1>
<p>{{ project.description || `${workspaceStore.activeWorkspace?.name} delivery stream with only the content scheduled in this campaign.` }}</p>
<h1>{{ campaign.name }}</h1>
<p>{{ campaign.description || `${workspaceStore.activeWorkspace?.name} delivery stream with only the content scheduled in this campaign.` }}</p>
</div>
<div class="hero-meta">
<div class="meta-chip">{{ project.status }}</div>
<div class="meta-copy">{{ formatProjectDateRange(project) }}</div>
<div class="meta-chip">{{ campaign.status }}</div>
<div class="meta-copy">{{ formatCampaignDateRange(campaign) }}</div>
</div>
</div>
<div
v-if="project.notes"
v-if="campaign.notes"
class="page-message"
>
{{ project.notes }}
{{ campaign.notes }}
</div>
<div class="section-header">
@@ -91,10 +91,10 @@
<div class="scope-actions">
<router-link
v-if="authStore.isManager || authStore.isProvider"
:to="{ name: 'content-item-create', query: { projectId: project.id } }"
:to="{ name: 'content-item-create', query: { campaignId: campaign.id } }"
class="scope-button"
>
New content in {{ project.name }}
New content in {{ campaign.name }}
</router-link>
</div>

View File

@@ -5,13 +5,13 @@
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
const route = useRoute();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const clientsStore = useClientsStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const { t } = useI18n();
const isCreateFormVisible = ref(false);
const formError = ref(null);
@@ -41,29 +41,29 @@
}
async function submitForm() {
if (projectsStore.isCreating) {
if (campaignsStore.isCreating) {
return;
}
formError.value = null;
if (!form.name || !form.startDate || !form.endDate) {
formError.value = t('projects.errors.required');
formError.value = t('campaigns.errors.required');
return;
}
if (new Date(form.endDate) < new Date(form.startDate)) {
formError.value = t('projects.errors.invalidDateRange');
formError.value = t('campaigns.errors.invalidDateRange');
return;
}
if (!operationalClient.value?.id) {
formError.value = t('projects.errors.workspaceAccountRequired');
formError.value = t('campaigns.errors.workspaceAccountRequired');
return;
}
try {
await projectsStore.createProject({
await campaignsStore.createCampaign({
clientId: operationalClient.value.id,
name: form.name,
startDate: new Date(form.startDate).toISOString(),
@@ -75,7 +75,7 @@
isCreateFormVisible.value = false;
resetForm();
} catch (error) {
formError.value = t('projects.errors.createFailed');
formError.value = t('campaigns.errors.createFailed');
}
}
@@ -89,13 +89,13 @@
{ immediate: true }
);
function formatProjectDateRange(project) {
if (!project?.startDate || !project?.endDate) {
return t('projects.noDateRange');
function formatCampaignDateRange(campaign) {
if (!campaign?.startDate || !campaign?.endDate) {
return t('campaigns.noDateRange');
}
const start = new Date(project.startDate);
const end = new Date(project.endDate);
const start = new Date(campaign.startDate);
const end = new Date(campaign.endDate);
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
@@ -108,9 +108,9 @@
<section class="page-shell">
<div class="header">
<div>
<div class="eyebrow">{{ t('projects.eyebrow') }}</div>
<h1>{{ t('projects.title') }}</h1>
<p>{{ t('projects.description') }}</p>
<div class="eyebrow">{{ t('campaigns.eyebrow') }}</div>
<h1>{{ t('campaigns.title') }}</h1>
<p>{{ t('campaigns.description') }}</p>
</div>
</div>
@@ -120,7 +120,7 @@
class="create-button"
@click="openCreateForm"
>
{{ t('projects.newProject') }}
{{ t('campaigns.newCampaign') }}
</button>
</div>
@@ -129,7 +129,7 @@
class="create-panel"
>
<div class="panel-header">
<strong>{{ t('projects.createTitle') }}</strong>
<strong>{{ t('campaigns.createTitle') }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
</div>
@@ -142,45 +142,45 @@
<div class="form-grid">
<label class="field">
<span>{{ t('projects.fields.startDate') }}</span>
<span>{{ t('campaigns.fields.startDate') }}</span>
<input
v-model="form.startDate"
type="date"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field">
<span>{{ t('projects.fields.endDate') }}</span>
<span>{{ t('campaigns.fields.endDate') }}</span>
<input
v-model="form.endDate"
type="date"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.name') }}</span>
<span>{{ t('campaigns.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.description') }}</span>
<span>{{ t('campaigns.fields.description') }}</span>
<textarea
v-model="form.description"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
></textarea>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.notes') }}</span>
<span>{{ t('campaigns.fields.notes') }}</span>
<textarea
v-model="form.notes"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
></textarea>
</label>
</div>
@@ -188,64 +188,64 @@
<div class="panel-actions">
<button
class="secondary"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
class="primary"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
@click="submitForm"
>
<v-progress-circular
v-if="projectsStore.isCreating"
v-if="campaignsStore.isCreating"
indeterminate
:size="16"
:width="2"
/>
<span>{{ projectsStore.isCreating ? t('common.creating') : t('projects.createTitle') }}</span>
<span>{{ campaignsStore.isCreating ? t('common.creating') : t('campaigns.createTitle') }}</span>
</button>
</div>
</div>
<div
v-if="projectsStore.isLoading"
v-if="campaignsStore.isLoading"
class="page-message"
>
{{ t('projects.loading') }}
{{ t('campaigns.loading') }}
</div>
<div
v-else-if="projectsStore.error"
v-else-if="campaignsStore.error"
class="page-message error"
>
{{ projectsStore.error }}
{{ campaignsStore.error }}
</div>
<div class="project-stack">
<div class="campaign-stack">
<router-link
v-for="project in projectsStore.projects"
:key="project.id"
:to="{ name: 'campaign-detail', params: { projectId: project.id } }"
class="project-row"
v-for="campaign in campaignsStore.campaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="campaign-row"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.description || project.status }}</span>
<strong>{{ campaign.name }}</strong>
<span>{{ campaign.description || campaign.status }}</span>
</div>
<div class="project-meta">
<div class="campaign-meta">
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
<em>{{ formatProjectDateRange(project) }}</em>
<em>{{ formatCampaignDateRange(campaign) }}</em>
</div>
</router-link>
</div>
<div
v-if="!projectsStore.isLoading && !projectsStore.projects.length"
v-if="!campaignsStore.isLoading && !campaignsStore.campaigns.length"
class="page-message"
>
{{ t('projects.empty') }}
{{ t('campaigns.empty') }}
</div>
</section>
</template>
@@ -267,9 +267,9 @@
.header p,
.panel-header span,
.project-row span,
.project-meta span,
.project-meta em {
.campaign-row span,
.campaign-meta span,
.campaign-meta em {
@apply text-sm leading-6 not-italic;
color: #526178;
}
@@ -296,7 +296,7 @@
}
.create-panel,
.project-row {
.campaign-row {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
@@ -311,7 +311,7 @@
}
.panel-header strong,
.project-row strong {
.campaign-row strong {
color: #172033;
}
@@ -347,19 +347,19 @@
@apply flex justify-end gap-3;
}
.project-stack {
.campaign-stack {
@apply flex flex-col gap-4;
}
.project-row {
.campaign-row {
@apply flex flex-col justify-between gap-4 p-5 no-underline lg:flex-row lg:items-center;
}
.project-row strong {
.campaign-row strong {
@apply block text-xl font-black;
}
.project-meta {
.campaign-meta {
@apply flex flex-col items-start gap-1 lg:items-end;
}

View File

@@ -5,13 +5,13 @@
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const authStore = useAuthStore();
const route = useRoute();
const clientsStore = useClientsStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore();
const isEditFormVisible = ref(false);
const isPortraitDialogOpen = ref(false);
@@ -48,9 +48,9 @@
clientsStore.clients.find(candidate => candidate.id === route.params.clientId) ?? null
);
const scopedProjects = computed(() =>
projectsStore.projects
.filter(project => project.clientId === route.params.clientId)
const scopedCampaigns = computed(() =>
campaignsStore.campaigns
.filter(campaign => campaign.clientId === route.params.clientId)
.sort((left, right) => {
const leftDue = left.endDate ? new Date(left.endDate).getTime() : Number.MAX_SAFE_INTEGER;
const rightDue = right.endDate ? new Date(right.endDate).getTime() : Number.MAX_SAFE_INTEGER;
@@ -58,26 +58,26 @@
})
);
const currentProjects = computed(() =>
scopedProjects.value.filter(project => project.status !== 'Completed' && project.status !== 'Archived')
const currentCampaigns = computed(() =>
scopedCampaigns.value.filter(campaign => campaign.status !== 'Completed' && campaign.status !== 'Archived')
);
const pastProjects = computed(() =>
scopedProjects.value.filter(project => project.status === 'Completed' || project.status === 'Archived')
const pastCampaigns = computed(() =>
scopedCampaigns.value.filter(campaign => campaign.status === 'Completed' || campaign.status === 'Archived')
);
const itemCountByProjectId = computed(() => {
const itemCountByCampaignId = computed(() => {
const counts = new Map();
for (const item of contentItemsStore.items.filter(candidate => candidate.clientId === route.params.clientId)) {
counts.set(item.projectId, (counts.get(item.projectId) ?? 0) + 1);
counts.set(item.campaignId, (counts.get(item.campaignId) ?? 0) + 1);
}
return counts;
});
function formatProjectDateRange(project) {
if (!project?.startDate || !project?.endDate) {
function formatCampaignDateRange(campaign) {
if (!campaign?.startDate || !campaign?.endDate) {
return 'No date range';
}
@@ -85,7 +85,7 @@
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(new Date(project.startDate), new Date(project.endDate));
}).formatRange(new Date(campaign.startDate), new Date(campaign.endDate));
}
function syncForm() {
@@ -188,18 +188,18 @@
<div class="hero-meta">
<span class="hero-status">{{ client.status }}</span>
</div>
<p>The client area scopes projects and content so review stays inside one account.</p>
<p>The client area scopes campaigns and content so review stays inside one account.</p>
</div>
</div>
<div class="stats-grid">
<article class="stat-card">
<span>Current campaigns</span>
<strong>{{ currentProjects.length }}</strong>
<strong>{{ currentCampaigns.length }}</strong>
</article>
<article class="stat-card">
<span>Past campaigns</span>
<strong>{{ pastProjects.length }}</strong>
<strong>{{ pastCampaigns.length }}</strong>
</article>
<article class="stat-card">
<span>Total content items</span>
@@ -420,26 +420,26 @@
<div class="section">
<div class="section-header">
<strong>Current campaigns</strong>
<span>{{ currentProjects.length }} active</span>
<span>{{ currentCampaigns.length }} active</span>
</div>
<div
v-if="currentProjects.length"
class="project-list"
v-if="currentCampaigns.length"
class="campaign-list"
>
<router-link
v-for="project in currentProjects"
:key="project.id"
:to="{ name: 'client-project-detail', params: { clientId: client.id, projectId: project.id } }"
class="project-card"
v-for="campaign in currentCampaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="campaign-card"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.status }}</span>
<strong>{{ campaign.name }}</strong>
<span>{{ campaign.status }}</span>
</div>
<div class="project-meta">
<small>{{ itemCountByProjectId.get(project.id) ?? 0 }} content items</small>
<em>{{ formatProjectDateRange(project) }}</em>
<div class="campaign-meta">
<small>{{ itemCountByCampaignId.get(campaign.id) ?? 0 }} content items</small>
<em>{{ formatCampaignDateRange(campaign) }}</em>
</div>
</router-link>
</div>
@@ -454,26 +454,26 @@
<div class="section">
<div class="section-header">
<strong>Past campaigns</strong>
<span>{{ pastProjects.length }} archived or completed</span>
<span>{{ pastCampaigns.length }} archived or completed</span>
</div>
<div
v-if="pastProjects.length"
class="project-list"
v-if="pastCampaigns.length"
class="campaign-list"
>
<router-link
v-for="project in pastProjects"
:key="project.id"
:to="{ name: 'client-project-detail', params: { clientId: client.id, projectId: project.id } }"
class="project-card muted"
v-for="campaign in pastCampaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="campaign-card muted"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.status }}</span>
<strong>{{ campaign.name }}</strong>
<span>{{ campaign.status }}</span>
</div>
<div class="project-meta">
<small>{{ itemCountByProjectId.get(project.id) ?? 0 }} content items</small>
<em>{{ formatProjectDateRange(project) }}</em>
<div class="campaign-meta">
<small>{{ itemCountByCampaignId.get(campaign.id) ?? 0 }} content items</small>
<em>{{ formatCampaignDateRange(campaign) }}</em>
</div>
</router-link>
</div>
@@ -489,7 +489,7 @@
.hero,
.stat-card,
.project-card {
.campaign-card {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
@@ -501,7 +501,7 @@
.hero-main h1,
.stat-card strong,
.project-card strong,
.campaign-card strong,
.contact-card strong {
color: #172033;
}
@@ -513,9 +513,9 @@
.hero-main p,
.breadcrumb,
.stat-card span,
.project-card span,
.project-card small,
.project-card em,
.campaign-card span,
.campaign-card small,
.campaign-card em,
.section-header span {
@apply text-sm leading-6 not-italic;
color: #526178;
@@ -675,27 +675,27 @@
color: #172033;
}
.project-list {
.campaign-list {
@apply grid gap-4 md:grid-cols-2;
}
.project-card {
.campaign-card {
@apply flex flex-col gap-4 p-5 no-underline transition;
}
.project-card:hover {
.campaign-card:hover {
transform: translateY(-2px);
}
.project-card.muted {
.campaign-card.muted {
background: rgba(255, 250, 242, 0.88);
}
.project-card span {
.campaign-card span {
@apply uppercase tracking-[0.16em];
}
.project-meta {
.campaign-meta {
@apply flex items-center justify-between gap-3;
}

View File

@@ -34,7 +34,7 @@ export const useContentItemsStore = defineStore('content-items', () => {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
clientId: filters.clientId,
projectId: filters.projectId,
campaignId: filters.campaignId,
},
});

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