Add real workspace channels

This commit is contained in:
2026-05-05 13:06:57 -04:00
parent 6e658b8215
commit 244be555f9
26 changed files with 2598 additions and 128 deletions

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Assets.Data; using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Channels.Data;
using Socialize.Api.Modules.Clients.Data; using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
@@ -22,6 +23,7 @@ public class AppDbContext(
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>(); public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
public DbSet<Workspace> Workspaces => Set<Workspace>(); public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>(); public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<Client> Clients => Set<Client>(); public DbSet<Client> Clients => Set<Client>();
public DbSet<Campaign> Campaigns => Set<Campaign>(); public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<ContentItem> ContentItems => Set<ContentItem>(); public DbSet<ContentItem> ContentItems => Set<ContentItem>();
@@ -46,6 +48,7 @@ public class AppDbContext(
builder.ConfigureOrganizationsModule(); builder.ConfigureOrganizationsModule();
builder.ConfigureWorkspacesModule(); builder.ConfigureWorkspacesModule();
builder.ConfigureChannelsModule();
builder.ConfigureClientsModule(); builder.ConfigureClientsModule();
builder.ConfigureCampaignsModule(); builder.ConfigureCampaignsModule();
builder.ConfigureContentItemsModule(); builder.ConfigureContentItemsModule();

View File

@@ -6,6 +6,7 @@ using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Assets.Data; using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Channels.Data;
using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Clients.Data; using Socialize.Api.Modules.Clients.Data;
@@ -23,10 +24,14 @@ public static class DevelopmentSeedExtensions
{ {
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999"); private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid AtlasWorkspaceId = Guid.Parse("11111111-1111-1111-1111-222222222222");
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222"); 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 HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
private static readonly Guid ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333"); 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 HiddenCampaignId = Guid.Parse("33333333-3333-3333-3333-444444444444");
private static readonly Guid LumaInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000001");
private static readonly Guid LumaTikTokChannelId = Guid.Parse("33333333-3333-3333-3333-000000000002");
private static readonly Guid AtlasInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000003");
private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-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 HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555"); private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
@@ -252,7 +257,7 @@ public static class DevelopmentSeedExtensions
dbContext.Organizations.Add(organization); dbContext.Organizations.Add(organization);
} }
organization.Name = "Northstar Collective"; organization.Name = "Northstar Agency";
organization.OwnerUserId = managerUserId; organization.OwnerUserId = managerUserId;
await UpsertOrganizationMembershipAsync( await UpsertOrganizationMembershipAsync(
@@ -309,32 +314,31 @@ public static class DevelopmentSeedExtensions
AppDbContext dbContext, AppDbContext dbContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Workspace? workspace = await dbContext.Workspaces await UpsertWorkspaceAsync(
.SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken); dbContext,
if (workspace is null) WorkspaceId,
{ OrganizationId,
workspace = new Workspace managerUserId,
{ "Luma Coffee",
Id = WorkspaceId, "America/Montreal",
Name = string.Empty, "/images/seed/luma-coffee-logo.svg",
TimeZone = string.Empty, cancellationToken);
CreatedAt = DateTimeOffset.UtcNow, await UpsertWorkspaceAsync(
}; dbContext,
dbContext.Workspaces.Add(workspace); AtlasWorkspaceId,
} OrganizationId,
managerUserId,
workspace.Name = "Northstar Studio"; "Atlas Bakery",
workspace.OrganizationId = OrganizationId; "America/Montreal",
workspace.OwnerUserId = managerUserId; "/images/seed/atlas-bakery-logo.svg",
workspace.TimeZone = "America/Montreal"; cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await UpsertClientAsync( await UpsertClientAsync(
dbContext, dbContext,
ScopedClientId, ScopedClientId,
"Luma Coffee", "Luma Coffee",
"Active", "Active",
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80", "/images/seed/luma-coffee-logo.svg",
"Sofia Martin", "Sofia Martin",
"client@socialize.local", "client@socialize.local",
WorkspaceId, WorkspaceId,
@@ -344,10 +348,10 @@ public static class DevelopmentSeedExtensions
HiddenClientId, HiddenClientId,
"Atlas Bakery", "Atlas Bakery",
"Active", "Active",
"https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=200&q=80", "/images/seed/atlas-bakery-logo.svg",
"Nina Cole", "Nina Cole",
"nina@atlasbakery.test", "nina@atlasbakery.test",
WorkspaceId, AtlasWorkspaceId,
cancellationToken); cancellationToken);
await UpsertCampaignAsync( await UpsertCampaignAsync(
@@ -365,7 +369,7 @@ public static class DevelopmentSeedExtensions
await UpsertCampaignAsync( await UpsertCampaignAsync(
dbContext, dbContext,
HiddenCampaignId, HiddenCampaignId,
WorkspaceId, AtlasWorkspaceId,
HiddenClientId, HiddenClientId,
"Summer Retention", "Summer Retention",
"Planned", "Planned",
@@ -375,6 +379,34 @@ public static class DevelopmentSeedExtensions
"Sequence email and paid social updates together.", "Sequence email and paid social updates together.",
cancellationToken); cancellationToken);
await UpsertChannelAsync(
dbContext,
LumaInstagramChannelId,
WorkspaceId,
"Luma Coffee Instagram",
"Instagram",
"@lumacoffee",
null,
cancellationToken);
await UpsertChannelAsync(
dbContext,
LumaTikTokChannelId,
WorkspaceId,
"Luma Coffee TikTok",
"TikTok",
"@lumacoffee",
null,
cancellationToken);
await UpsertChannelAsync(
dbContext,
AtlasInstagramChannelId,
AtlasWorkspaceId,
"Atlas Bakery Instagram",
"Instagram",
"@atlasbakery",
null,
cancellationToken);
await UpsertContentItemAsync( await UpsertContentItemAsync(
dbContext, dbContext,
ScopedContentItemId, ScopedContentItemId,
@@ -383,7 +415,7 @@ public static class DevelopmentSeedExtensions
ScopedCampaignId, ScopedCampaignId,
"Spring launch hero video", "Spring launch hero video",
"Fresh seasonal menu launch across Instagram and TikTok.", "Fresh seasonal menu launch across Instagram and TikTok.",
"Instagram Reel, TikTok", "Luma Coffee Instagram, Luma Coffee TikTok",
"In approval", "In approval",
DateTimeOffset.UtcNow.AddDays(3), DateTimeOffset.UtcNow.AddDays(3),
"v3", "v3",
@@ -392,22 +424,22 @@ public static class DevelopmentSeedExtensions
await UpsertContentItemAsync( await UpsertContentItemAsync(
dbContext, dbContext,
HiddenContentItemId, HiddenContentItemId,
WorkspaceId, AtlasWorkspaceId,
HiddenClientId, HiddenClientId,
HiddenCampaignId, HiddenCampaignId,
"Bakery loyalty carousel", "Bakery loyalty carousel",
"Reward regular customers with a four-card retention carousel.", "Reward regular customers with a four-card retention carousel.",
"Instagram Carousel", "Atlas Bakery Instagram",
"Draft", "Draft",
DateTimeOffset.UtcNow.AddDays(10), DateTimeOffset.UtcNow.AddDays(10),
"v1", "v1",
1, 1,
cancellationToken); cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Instagram Reel, TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken); await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Luma Coffee Instagram, Luma Coffee TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken); await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Luma Coffee Instagram, Luma Coffee TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken); await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Luma Coffee Instagram, Luma Coffee TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken); await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Atlas Bakery Instagram", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken); Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
if (asset is null) if (asset is null)
@@ -535,6 +567,38 @@ public static class DevelopmentSeedExtensions
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
private static async Task UpsertWorkspaceAsync(
AppDbContext dbContext,
Guid id,
Guid organizationId,
Guid ownerUserId,
string name,
string timeZone,
string logoUrl,
CancellationToken cancellationToken)
{
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (workspace is null)
{
workspace = new Workspace
{
Id = id,
Name = string.Empty,
TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Workspaces.Add(workspace);
}
workspace.Name = name;
workspace.OrganizationId = organizationId;
workspace.OwnerUserId = ownerUserId;
workspace.TimeZone = timeZone;
workspace.LogoUrl = logoUrl;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertClientAsync( private static async Task UpsertClientAsync(
AppDbContext dbContext, AppDbContext dbContext,
Guid id, Guid id,
@@ -604,6 +668,37 @@ public static class DevelopmentSeedExtensions
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
private static async Task UpsertChannelAsync(
AppDbContext dbContext,
Guid id,
Guid workspaceId,
string name,
string network,
string? handle,
string? externalUrl,
CancellationToken cancellationToken)
{
Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (channel is null)
{
channel = new Channel
{
Id = id,
Name = string.Empty,
Network = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Channels.Add(channel);
}
channel.WorkspaceId = workspaceId;
channel.Name = name;
channel.Network = network;
channel.Handle = handle;
channel.ExternalUrl = externalUrl;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertContentItemAsync( private static async Task UpsertContentItemAsync(
AppDbContext dbContext, AppDbContext dbContext,
Guid id, Guid id,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddChannels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Channels",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Network = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Handle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ExternalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Channels", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Channels_WorkspaceId",
table: "Channels",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Channels_WorkspaceId_Network_Name",
table: "Channels",
columns: new[] { "WorkspaceId", "Network", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Channels");
}
}
}

View File

@@ -491,6 +491,48 @@ namespace Socialize.Api.Migrations
b.ToTable("Campaigns", (string)null); b.ToTable("Campaigns", (string)null);
}); });
modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("ExternalUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Handle")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Network")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Network", "Name")
.IsUnique();
b.ToTable("Channels", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.Channels.Data;
public class Channel
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public required string Network { get; set; }
public string? Handle { get; set; }
public string? ExternalUrl { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Channels.Data;
public static class ChannelModelConfiguration
{
public static ModelBuilder ConfigureChannelsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<Channel>(channel =>
{
channel.ToTable("Channels");
channel.HasKey(x => x.Id);
channel.Property(x => x.Name).HasMaxLength(256).IsRequired();
channel.Property(x => x.Network).HasMaxLength(64).IsRequired();
channel.Property(x => x.Handle).HasMaxLength(256);
channel.Property(x => x.ExternalUrl).HasMaxLength(2048);
channel.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
channel.HasIndex(x => x.WorkspaceId);
channel.HasIndex(x => new { x.WorkspaceId, x.Network, x.Name }).IsUnique();
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Channels;
public static class DependencyInjection
{
public static WebApplicationBuilder AddChannelsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Channels.Handlers;
public record ChannelDto(
Guid Id,
Guid WorkspaceId,
string Name,
string Network,
string? Handle,
string? ExternalUrl,
DateTimeOffset CreatedAt);

View File

@@ -0,0 +1,115 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers;
public record CreateChannelRequest(
Guid WorkspaceId,
string Name,
string Network,
string? Handle,
string? ExternalUrl);
public class CreateChannelRequestValidator
: Validator<CreateChannelRequest>
{
private static readonly string[] AllowedNetworks =
[
"Instagram",
"TikTok",
"Facebook",
"LinkedIn",
"YouTube",
"X",
"Reddit",
"Website",
];
public CreateChannelRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Network).NotEmpty().Must(network => AllowedNetworks.Contains(network))
.WithMessage("Selected network is invalid.");
RuleFor(x => x.Handle).MaximumLength(256);
RuleFor(x => x.ExternalUrl).MaximumLength(2048);
}
}
public class CreateChannelHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateChannelRequest, ChannelDto>
{
public override void Configure()
{
Post("/api/channels");
Options(o => o.WithTags("Channels"));
}
public override async Task HandleAsync(CreateChannelRequest request, CancellationToken ct)
{
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedNetwork = request.Network.Trim();
string? normalizedHandle = request.Handle?.Trim();
string? normalizedExternalUrl = request.ExternalUrl?.Trim();
bool duplicateChannel = await dbContext.Channels
.AnyAsync(
channel => channel.WorkspaceId == request.WorkspaceId
&& channel.Network == normalizedNetwork
&& channel.Name == normalizedName,
ct);
if (duplicateChannel)
{
AddError(request => request.Name, "A channel with this name already exists for the selected network.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
Channel channel = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
Name = normalizedName,
Network = normalizedNetwork,
Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle,
ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Channels.Add(channel);
await dbContext.SaveChangesAsync(ct);
ChannelDto dto = new(
channel.Id,
channel.WorkspaceId,
channel.Name,
channel.Network,
channel.Handle,
channel.ExternalUrl,
channel.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,52 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers;
public record GetChannelsRequest(Guid? WorkspaceId);
public class GetChannelsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetChannelsRequest, IReadOnlyCollection<ChannelDto>>
{
public override void Configure()
{
Get("/api/channels");
Options(o => o.WithTags("Channels"));
}
public override async Task HandleAsync(GetChannelsRequest request, CancellationToken ct)
{
IQueryable<Channel> query = dbContext.Channels.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(channel => channel.WorkspaceId == request.WorkspaceId.Value);
}
List<ChannelDto> channels = await query
.OrderBy(channel => channel.Network)
.ThenBy(channel => channel.Name)
.Select(channel => new ChannelDto(
channel.Id,
channel.WorkspaceId,
channel.Name,
channel.Network,
channel.Handle,
channel.ExternalUrl,
channel.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(channels, ct);
}
}

View File

@@ -10,6 +10,7 @@ using Socialize.Api.Infrastructure;
using Socialize.Api.Infrastructure.Development; using Socialize.Api.Infrastructure.Development;
using Socialize.Api.Modules.Approvals; using Socialize.Api.Modules.Approvals;
using Socialize.Api.Modules.Assets; using Socialize.Api.Modules.Assets;
using Socialize.Api.Modules.Channels;
using Socialize.Api.Modules.Clients; using Socialize.Api.Modules.Clients;
using Socialize.Api.Modules.Comments; using Socialize.Api.Modules.Comments;
using Socialize.Api.Modules.ContentItems; using Socialize.Api.Modules.ContentItems;
@@ -65,6 +66,7 @@ builder.AddInfrastructureModule();
builder.AddIdentityModule(); builder.AddIdentityModule();
builder.AddOrganizationsModule(); builder.AddOrganizationsModule();
builder.AddWorkspaceModule(); builder.AddWorkspaceModule();
builder.AddChannelsModule();
builder.AddClientsModule(); builder.AddClientsModule();
builder.AddCampaignsModule(); builder.AddCampaignsModule();
builder.AddContentItemsModule(); builder.AddContentItemsModule();

43
docs/FEATURES/channels.md Normal file
View File

@@ -0,0 +1,43 @@
# Channels
Channels are configured social destinations inside a workspace. They represent the account, handle, page, feed, newsletter, or other publication destination where content will eventually be handed off for publishing.
Channels are workspace-owned data. Organization-owned connectors may provide credentials for external systems, but the workspace owns which destinations are available for content planning.
## Model
A channel has:
- `id`
- `workspaceId`
- `name`
- `network`
- optional `handle`
- optional `externalUrl`
- `createdAt`
`network` is a controlled string matching the frontend channel network options:
- `Instagram`
- `TikTok`
- `Facebook`
- `LinkedIn`
- `YouTube`
- `X`
- `Reddit`
- `Website`
Channel names must be unique inside a workspace for the same network.
## Behavior
- Authenticated users with workspace access can list channels for their active workspace.
- Workspace managers can create channels.
- Content planning uses configured channels as selectable destinations.
- Development seed data should create real workspace channels instead of relying on content target labels as fake channels.
## Not In Scope
- External connector credentials.
- Publishing directly to social networks.
- Channel deletion, archiving, or editing.

View File

@@ -0,0 +1,23 @@
# Task: Add content detail back navigation
## Goal
Make it easy to return to the Content calendar or upcoming list after opening a content item detail page.
## Scope
- Add a visible back control to `ContentItemDetailView`.
- Preserve the originating Content page view state when navigating from calendar or upcoming entries.
- Keep the change frontend-only.
## Relevant Files
- `frontend/src/features/content/views/ContentItemsView.vue`
- `frontend/src/features/content/views/ContentItemDetailView.vue`
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,34 @@
# Task: Add real workspace channels
## Feature
`docs/FEATURES/channels.md`
## Goal
Replace frontend-derived fake channels with real workspace-owned channel records served by the backend API.
## Scope
- Add a backend `Channels` module with a `Channel` table.
- Add list and create endpoints for workspace channels.
- Seed development channels and align seeded content publication targets to those configured channel names.
- Update the frontend channels store to load and create channels through the API.
- Keep the Channels page UI shape intact.
## Relevant Files
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/src/Socialize.Api/Modules/Channels/`
- `backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs`
- `frontend/src/features/channels/stores/channelsStore.js`
- `docs/FEATURES/channels.md`
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend
npm run build
```

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">Atlas Bakery logo</title>
<desc id="desc">A bakery wheat and loaf mark in rose and golden tones.</desc>
<rect width="256" height="256" rx="56" fill="#fff7ed"/>
<circle cx="128" cy="128" r="92" fill="#fffaf2" stroke="#9f1239" stroke-width="10"/>
<path d="M73 157c15-38 42-58 82-58 29 0 51 16 62 46 4 12-5 25-18 25H87c-11 0-18-7-14-13z" fill="#d97706"/>
<path d="M92 145c11-17 25-26 42-26M127 145c11-17 25-26 42-26M162 145c9-13 18-20 29-21" fill="none" stroke="#fffaf2" stroke-width="9" stroke-linecap="round"/>
<path d="M78 77c34 13 52 45 50 96" fill="none" stroke="#0f766e" stroke-width="8" stroke-linecap="round"/>
<path d="M75 78c18-18 36-15 48 6-20 9-36 7-48-6zM92 105c21-13 38-6 46 18-22 4-37-2-46-18z" fill="#0f766e"/>
<path d="M134 77c15-21 33-23 53-7-11 17-29 20-53 7zM132 109c20-14 38-9 51 14-18 9-36 4-51-14z" fill="#16a34a"/>
</svg>

After

Width:  |  Height:  |  Size: 982 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">Luma Coffee logo</title>
<desc id="desc">A warm coffee cup monogram inside a cream roundel.</desc>
<rect width="256" height="256" rx="56" fill="#f8fafc"/>
<circle cx="128" cy="128" r="92" fill="#fffaf2" stroke="#172033" stroke-width="10"/>
<path d="M82 104h78v38c0 26-18 45-43 45S74 168 74 142v-30a8 8 0 018-8z" fill="#8b4a2f"/>
<path d="M160 116h16c18 0 30 11 30 27s-12 27-30 27h-20v-18h20c8 0 13-4 13-9s-5-9-13-9h-16v-18z" fill="#8b4a2f"/>
<path d="M93 122h18v43h34v18H93v-61z" fill="#fffaf2"/>
<path d="M98 72c0-15 14-17 14-30M130 72c0-15 14-17 14-30M162 72c0-15 14-17 14-30" fill="none" stroke="#0f766e" stroke-width="9" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 794 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">Northstar Agency logo</title>
<desc id="desc">A navy square mark with a teal north star and orange accent.</desc>
<rect width="256" height="256" rx="56" fill="#172033"/>
<circle cx="128" cy="128" r="74" fill="#fffaf2" opacity="0.08"/>
<path d="M128 34l22.8 70.2H224l-59.2 43 22.6 69.8L128 173.8 68.6 217l22.6-69.8L32 104.2h73.2L128 34z" fill="#fffaf2"/>
<path d="M128 62l14.6 45h47.4l-38.4 27.9 14.8 45.5L128 152.3l-38.4 28.1 14.8-45.5L66 107h47.4L128 62z" fill="#0f766e"/>
<circle cx="188" cy="68" r="15" fill="#ff8a3d"/>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@@ -788,6 +788,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/channels": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesChannelsHandlersGetChannelsHandler"];
put?: never;
post: operations["SocializeApiModulesChannelsHandlersCreateChannelHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/campaigns": { "/api/campaigns": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1411,6 +1427,27 @@ export interface components {
primaryContactEmail?: string | null; primaryContactEmail?: string | null;
primaryContactPortraitUrl?: string | null; primaryContactPortraitUrl?: string | null;
}; };
SocializeApiModulesChannelsHandlersChannelDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
name?: string;
network?: string;
handle?: string | null;
externalUrl?: string | null;
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesChannelsHandlersCreateChannelRequest: {
/** Format: guid */
workspaceId: string;
name: string;
network: string;
handle?: string | null;
externalUrl?: string | null;
};
SocializeApiModulesChannelsHandlersGetChannelsRequest: Record<string, never>;
SocializeApiModulesCampaignsHandlersCampaignDto: { SocializeApiModulesCampaignsHandlersCampaignDto: {
/** Format: guid */ /** Format: guid */
id?: string; id?: string;
@@ -3426,6 +3463,75 @@ export interface operations {
}; };
}; };
}; };
SocializeApiModulesChannelsHandlersGetChannelsHandler: {
parameters: {
query?: {
workspaceId?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesChannelsHandlersCreateChannelHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersCreateChannelRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCampaignsHandlersGetCampaignsHandler: { SocializeApiModulesCampaignsHandlersGetCampaignsHandler: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -1,52 +1,19 @@
import { computed } from 'vue'; import { ref, watch } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core'; import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; import { useClient } from '@/plugins/api.js';
export const useChannelsStore = defineStore('channels', () => { export const useChannelsStore = defineStore('channels', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore(); const workspaceStore = useWorkspaceStore();
const contentItemsStore = useContentItemsStore(); const client = useClient();
const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, {
serializer: {
read: value => (value ? JSON.parse(value) : {}),
write: value => JSON.stringify(value ?? {}),
},
});
const channels = computed(() => { const channels = ref([]);
const currentWorkspaceId = workspaceStore.activeWorkspaceId; const isLoading = ref(false);
const isCreating = ref(false);
if (!currentWorkspaceId) { const error = ref(null);
return []; const loadedWorkspaceId = ref(null);
}
const derivedChannels = new Map();
const customChannels = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
for (const item of contentItemsStore.items) {
for (const name of parseTargets(item.publicationTargets)) {
const key = normalizeChannelKey(name);
const existing = derivedChannels.get(key) ?? {
id: key,
name,
network: null,
source: 'derived',
};
derivedChannels.set(key, existing);
}
}
for (const channel of customChannels) {
derivedChannels.set(channel.id, {
...channel,
source: 'custom',
});
}
return [...derivedChannels.values()].sort((left, right) => left.name.localeCompare(right.name));
});
const availableNetworks = [ const availableNetworks = [
'Instagram', 'Instagram',
@@ -59,64 +26,102 @@ export const useChannelsStore = defineStore('channels', () => {
'Website', 'Website',
]; ];
function createChannel(payload) { async function fetchChannels({ force = false } = {}) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId; const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) { if (!authStore.isAuthenticated || !currentWorkspaceId) {
channels.value = [];
error.value = null;
loadedWorkspaceId.value = null;
return;
}
if (!force && loadedWorkspaceId.value === currentWorkspaceId) {
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/channels', {
params: {
workspaceId: currentWorkspaceId,
},
});
channels.value = response.data ?? [];
loadedWorkspaceId.value = currentWorkspaceId;
} catch (fetchError) {
console.error('Failed to fetch channels:', fetchError);
channels.value = [];
loadedWorkspaceId.value = null;
error.value = 'Failed to load channels.';
} finally {
isLoading.value = false;
}
}
async function createChannel(payload) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!authStore.isAuthenticated || !currentWorkspaceId) {
throw new Error('An active workspace is required to create a channel.'); throw new Error('An active workspace is required to create a channel.');
} }
const normalizedName = payload.name.trim(); if (isCreating.value) {
const normalizedNetwork = payload.network.trim(); throw new Error('A channel creation request is already in progress.');
if (!normalizedName) {
throw new Error('Channel name is required.');
} }
if (!normalizedNetwork) { isCreating.value = true;
throw new Error('Network is required.'); error.value = null;
try {
const response = await client.post('/api/channels', {
...payload,
workspaceId: currentWorkspaceId,
});
if (response.data) {
channels.value = [...channels.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
} }
if (!availableNetworks.includes(normalizedNetwork)) { return response.data;
throw new Error('Selected network is invalid.'); } catch (createError) {
console.error('Failed to create channel:', createError);
const message = createError.response?.data?.errors?.[0]?.reason
?? createError.response?.data?.message
?? 'Failed to create channel.';
error.value = message;
throw new Error(message);
} finally {
isCreating.value = false;
}
} }
const existing = channels.value.some(channel => watch(
channel.name.toLowerCase() === normalizedName.toLowerCase() () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
&& (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase() async ([isAuthenticated, workspaceId]) => {
); if (!isAuthenticated || !workspaceId) {
if (existing) { channels.value = [];
throw new Error('A channel with this name already exists for the selected network.'); error.value = null;
loadedWorkspaceId.value = null;
return;
} }
const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? []; await fetchChannels();
customChannelsByWorkspace.value = {
...customChannelsByWorkspace.value,
[currentWorkspaceId]: [
...next,
{
id: normalizeChannelKey(`${normalizedNetwork}-${normalizedName}`),
name: normalizedName,
network: normalizedNetwork,
}, },
], { immediate: true }
}; );
}
function parseTargets(value) {
return (value ?? '')
.split(/[,\n]+/)
.map(target => target.trim())
.filter(Boolean);
}
function normalizeChannelKey(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
return { return {
availableNetworks, availableNetworks,
channels, channels,
isLoading,
isCreating,
error,
fetchChannels,
createChannel, createChannel,
}; };
}); });

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
@@ -87,11 +87,11 @@
isCreateFormVisible.value = true; isCreateFormVisible.value = true;
} }
function submitForm() { async function submitForm() {
formError.value = null; formError.value = null;
try { try {
channelsStore.createChannel({ await channelsStore.createChannel({
name: form.name, name: form.name,
network: form.network, network: form.network,
}); });
@@ -118,6 +118,10 @@
}, },
{ immediate: true } { immediate: true }
); );
onMounted(() => {
channelsStore.fetchChannels();
});
</script> </script>
<template> <template>
@@ -178,15 +182,30 @@
<button <button
class="primary" class="primary"
type="button" type="button"
:disabled="channelsStore.isCreating"
@click="submitForm" @click="submitForm"
> >
{{ t('channels.createTitle') }} {{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
</button> </button>
</div> </div>
</div> </div>
<div <div
v-if="channelsForActiveNetwork.length" v-if="channelsStore.isLoading"
class="page-message"
>
{{ t('loading') }}
</div>
<div
v-else-if="channelsStore.error"
class="page-message error"
>
{{ channelsStore.error }}
</div>
<div
v-else-if="channelsForActiveNetwork.length"
class="channel-grid" class="channel-grid"
> >
<article <article

View File

@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, reactive, watch } from 'vue'; import { computed, onBeforeUnmount, reactive, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
import { mdiArrowLeft } from '@mdi/js';
import AppAvatar from '@/components/AppAvatar.vue'; import AppAvatar from '@/components/AppAvatar.vue';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js'; import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js'; import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
@@ -332,6 +333,23 @@
commentForm.body = ''; commentForm.body = '';
} }
async function navigateBackToContent() {
const returnTo = typeof route.query.returnTo === 'string' ? route.query.returnTo : '';
const previousPath = router.options.history.state.back;
if (returnTo.startsWith('/app/content')) {
await router.push(returnTo);
return;
}
if (typeof previousPath === 'string' && previousPath.startsWith('/app/content')) {
router.back();
return;
}
await router.push({ name: 'content-items' });
}
function formatDateTime(value) { function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : ''; return value ? new Date(value).toLocaleString() : '';
} }
@@ -373,6 +391,15 @@
<template> <template>
<section class="editor-shell"> <section class="editor-shell">
<button
class="back-button"
type="button"
@click="navigateBackToContent"
>
<v-icon :icon="mdiArrowLeft" />
Back to content
</button>
<div <div
v-if="!isCreateMode && detailStore.isLoading" v-if="!isCreateMode && detailStore.isLoading"
class="page-message" class="page-message"
@@ -838,9 +865,22 @@
color: #172033; color: #172033;
} }
.back-button,
.primary-button, .primary-button,
.secondary-button { .secondary-button {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition; @apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
}
.back-button {
@apply w-fit border;
background: rgba(255, 255, 255, 0.88);
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
}
.back-button:hover {
background: #172033;
color: #fffaf2;
} }
.primary-button { .primary-button {

View File

@@ -1,17 +1,20 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js'; import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
const campaignsStore = useCampaignsStore(); const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore(); const contentItemsStore = useContentItemsStore();
const today = startOfDay(new Date()); const today = startOfDay(new Date());
const viewMode = ref('month'); const viewMode = ref(parseViewMode(route.query.view));
const cursorDate = ref(today); const cursorDate = ref(parseCursorDate(route.query.date, today));
const contentStatusMeta = { const contentStatusMeta = {
Draft: { tone: 'production' }, Draft: { tone: 'production' },
@@ -165,7 +168,7 @@
dayKey: dateKey(item.dueDate), dayKey: dateKey(item.dueDate),
timeLabel: formatHour(item.dueDate), timeLabel: formatHour(item.dueDate),
tone: statusMeta.tone, tone: statusMeta.tone,
route: { name: 'content-item-detail', params: { id: item.id } }, route: { name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } },
}; };
} }
@@ -275,6 +278,45 @@
function sortByDate(left, right) { function sortByDate(left, right) {
return left.scheduledAt.getTime() - right.scheduledAt.getTime(); return left.scheduledAt.getTime() - right.scheduledAt.getTime();
} }
function parseViewMode(value) {
return ['month', 'week', 'upcoming'].includes(value) ? value : 'month';
}
function parseCursorDate(value, fallback) {
if (typeof value !== 'string') {
return fallback;
}
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? fallback : startOfDay(parsed);
}
watch(
() => route.query,
query => {
viewMode.value = parseViewMode(query.view);
cursorDate.value = parseCursorDate(query.date, today);
}
);
watch(
() => [viewMode.value, dateKey(cursorDate.value)],
([view, date]) => {
if (route.query.view === view && route.query.date === date) {
return;
}
router.replace({
name: 'content-items',
query: {
...route.query,
view,
date,
},
});
}
);
</script> </script>
<template> <template>
@@ -429,7 +471,7 @@
<router-link <router-link
v-for="item in upcomingItems" v-for="item in upcomingItems"
:key="item.id" :key="item.id"
:to="{ name: 'content-item-detail', params: { id: item.id } }" :to="{ name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } }"
class="item-card" class="item-card"
> >
<div class="version-chip">{{ item.currentRevisionLabel }}</div> <div class="version-chip">{{ item.currentRevisionLabel }}</div>

View File

@@ -443,6 +443,8 @@
} }
}, },
"nav": { "nav": {
"brandStage": "Preview",
"brandStageLabel": "Product stage: Preview",
"brandCaption": "Approval workflow", "brandCaption": "Approval workflow",
"workspace": "Workspace", "workspace": "Workspace",
"notifications": "Notifications", "notifications": "Notifications",
@@ -826,7 +828,6 @@
"selectCampaign": "Select a campaign", "selectCampaign": "Select a campaign",
"dueDate": "Due date", "dueDate": "Due date",
"publicationTargets": "Publication targets", "publicationTargets": "Publication targets",
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
"publicationMessage": "Publication message", "publicationMessage": "Publication message",
"hashtags": "Hashtags", "hashtags": "Hashtags",
"hashtagsPlaceholder": "#launch #brand #campaign", "hashtagsPlaceholder": "#launch #brand #campaign",

View File

@@ -443,6 +443,8 @@
} }
}, },
"nav": { "nav": {
"brandStage": "Preview",
"brandStageLabel": "Statut du produit : Preview",
"brandCaption": "Flux d'approbation", "brandCaption": "Flux d'approbation",
"workspace": "Espace de travail", "workspace": "Espace de travail",
"notifications": "Notifications", "notifications": "Notifications",
@@ -826,7 +828,6 @@
"selectCampaign": "Sélectionner une campagne", "selectCampaign": "Sélectionner une campagne",
"dueDate": "Date d'échéance", "dueDate": "Date d'échéance",
"publicationTargets": "Cibles de publication", "publicationTargets": "Cibles de publication",
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
"publicationMessage": "Message de publication", "publicationMessage": "Message de publication",
"hashtags": "Hashtags", "hashtags": "Hashtags",
"hashtagsPlaceholder": "#lancement #marque #campagne", "hashtagsPlaceholder": "#lancement #marque #campagne",

View File

@@ -2539,6 +2539,99 @@
] ]
} }
}, },
"/api/channels": {
"post": {
"tags": [
"Channels",
"Api"
],
"operationId": "SocializeApiModulesChannelsHandlersCreateChannelHandler",
"requestBody": {
"x-name": "CreateChannelRequest",
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersCreateChannelRequest"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersChannelDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
},
"get": {
"tags": [
"Channels",
"Api"
],
"operationId": "SocializeApiModulesChannelsHandlersGetChannelsHandler",
"parameters": [
{
"name": "workspaceId",
"in": "query",
"schema": {
"type": "string",
"format": "guid",
"nullable": true
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersChannelDto"
}
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
}
},
"/api/campaigns": { "/api/campaigns": {
"post": { "post": {
"tags": [ "tags": [
@@ -4612,6 +4705,82 @@
} }
} }
}, },
"SocializeApiModulesChannelsHandlersChannelDto": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"format": "guid"
},
"workspaceId": {
"type": "string",
"format": "guid"
},
"name": {
"type": "string"
},
"network": {
"type": "string"
},
"handle": {
"type": "string",
"nullable": true
},
"externalUrl": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
},
"SocializeApiModulesChannelsHandlersCreateChannelRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"workspaceId",
"name",
"network"
],
"properties": {
"workspaceId": {
"type": "string",
"format": "guid",
"minLength": 1,
"nullable": false
},
"name": {
"type": "string",
"maxLength": 256,
"minLength": 0,
"nullable": false
},
"network": {
"type": "string",
"minLength": 1,
"nullable": false
},
"handle": {
"type": "string",
"maxLength": 256,
"minLength": 0,
"nullable": true
},
"externalUrl": {
"type": "string",
"maxLength": 2048,
"minLength": 0,
"nullable": true
}
}
},
"SocializeApiModulesChannelsHandlersGetChannelsRequest": {
"type": "object",
"additionalProperties": false
},
"SocializeApiModulesCampaignsHandlersCampaignDto": { "SocializeApiModulesCampaignsHandlersCampaignDto": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,